Coverage for src / lexigram / ui / accessibility / _aria_functions.py: 46%
68 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
1"""ARIA attribute helper functions for accessibility utilities."""
3from __future__ import annotations
5from lexigram.ui.accessibility._aria_types import AriaAttrs, AriaLive, AriaRole
6from lexigram.ui.core.base import el, render_to_string
8# Factory functions for common ARIA patterns
11def table_aria(
12 label: str,
13 rowcount: int | None = None,
14 colcount: int | None = None,
15 sortable: bool = False,
16) -> dict[str, str]:
17 """Return ARIA attributes for an accessible data table (grid role).
19 Use on the ``<table>`` or wrapper element that contains rows and cells.
20 The ``grid`` role is used instead of ``table`` to support interactive
21 keyboard navigation patterns expected by Admin UI tables.
23 Args:
24 label: Human-readable label describing the table's content, used as
25 ``aria-label``.
26 rowcount: Total number of data rows across all pages. Pass the full
27 dataset size when the table is paginated so assistive technologies
28 can announce ``"row N of M"``.
29 colcount: Total number of columns. Required when some columns are
30 hidden or the table uses column groups.
31 sortable: Reserved for future use. Pass ``True`` to signal that
32 column headers may carry ``aria-sort`` attributes.
34 Returns:
35 A ``dict[str, str]`` of HTML attribute names to their string values,
36 ready to be spread onto the element.
38 Example::
40 attrs = table_aria("User list", rowcount=250, colcount=5)
41 # {"role": "grid", "aria-label": "User list",
42 # "aria-rowcount": "250", "aria-colcount": "5"}
43 """
44 attrs = AriaAttrs(
45 role=AriaRole.GRID,
46 label=label,
47 rowcount=rowcount,
48 colcount=colcount,
49 )
50 return attrs.to_dict()
53def row_aria(
54 index: int,
55 selected: bool = False,
56 expanded: bool | None = None,
57) -> dict[str, str]:
58 """Return ARIA attributes for a table row element.
60 Args:
61 index: 1-based row position within the full dataset (not the current
62 page). Maps to ``aria-rowindex``.
63 selected: Whether this row is currently selected. Maps to
64 ``aria-selected``.
65 expanded: For tree-grid rows, whether the row is expanded. Pass
66 ``None`` (default) to omit the attribute entirely.
68 Returns:
69 A ``dict[str, str]`` of HTML attribute names to their string values.
70 """
71 attrs = AriaAttrs(
72 role=AriaRole.ROW,
73 rowindex=index,
74 selected=selected,
75 expanded=expanded,
76 )
77 return attrs.to_dict()
80def cell_aria(
81 colindex: int | None = None,
82 rowindex: int | None = None,
83) -> dict[str, str]:
84 """Return ARIA attributes for an interactive table data cell.
86 Uses the ``gridcell`` role, which is appropriate for cells inside a
87 ``grid``-role container that supports keyboard interaction.
89 Args:
90 colindex: 1-based column position within the full column set. Maps
91 to ``aria-colindex``. Required when columns are hidden.
92 rowindex: 1-based row position within the full dataset. Maps to
93 ``aria-rowindex``. Usually set on the row element instead;
94 provide here only when the row element cannot be annotated.
96 Returns:
97 A ``dict[str, str]`` of HTML attribute names to their string values.
98 """
99 attrs = AriaAttrs(
100 role=AriaRole.GRIDCELL,
101 colindex=colindex,
102 rowindex=rowindex,
103 )
104 return attrs.to_dict()
107def header_aria(
108 label: str,
109 sortable: bool = False,
110 sort_direction: str | None = None,
111) -> dict[str, str]:
112 """Return ARIA attributes for a sortable or static column header cell.
114 Args:
115 label: Accessible name for the column, mapped to ``aria-label``.
116 sortable: When ``True``, adds an ``aria-sort`` attribute to indicate
117 the column participates in sorting. Defaults to ``False``.
118 sort_direction: Current sort direction. Pass ``"asc"`` for ascending,
119 ``"desc"`` for descending, or ``None`` (default) to output
120 ``aria-sort="none"`` when *sortable* is ``True``.
122 Returns:
123 A ``dict[str, str]`` of HTML attribute names to their string values.
125 Example::
127 attrs = header_aria("Name", sortable=True, sort_direction="asc")
128 # {"role": "columnheader", "aria-label": "Name", "aria-sort": "ascending"}
129 """
130 sort = None
131 if sortable:
132 if sort_direction == "asc":
133 sort = "ascending"
134 elif sort_direction == "desc":
135 sort = "descending"
136 else:
137 sort = "none"
139 attrs = AriaAttrs(
140 role=AriaRole.COLUMNHEADER,
141 label=label,
142 sort=sort,
143 )
144 return attrs.to_dict()
147def button_aria(
148 label: str,
149 pressed: bool | None = None,
150 expanded: bool | None = None,
151 controls: str | None = None,
152 haspopup: str | None = None,
153 disabled: bool = False,
154) -> dict[str, str]:
155 """Return ARIA attributes for an interactive button element.
157 Covers toggle buttons, disclosure buttons, and menu-trigger buttons. Pass
158 only the arguments relevant to the button's role; unused attributes are
159 omitted from the returned dict.
161 Args:
162 label: Accessible name for the button, mapped to ``aria-label``.
163 pressed: For toggle buttons, the current pressed state. ``True``
164 maps to ``aria-pressed="true"``, ``False`` to ``"false"``, ``None``
165 omits the attribute entirely.
166 expanded: For disclosure/accordion buttons, whether the controlled
167 region is currently visible. Maps to ``aria-expanded``.
168 controls: ID of the element this button controls. Maps to
169 ``aria-controls``.
170 haspopup: Type of popup this button opens, e.g. ``"menu"``,
171 ``"listbox"``, ``"dialog"``. Maps to ``aria-haspopup``.
172 disabled: When ``True``, adds ``aria-disabled="true"``.
174 Returns:
175 A ``dict[str, str]`` of HTML attribute names to their string values.
177 Example::
179 attrs = button_aria("Toggle sidebar", expanded=False, controls="sidebar")
180 # {"role": "button", "aria-label": "Toggle sidebar",
181 # "aria-expanded": "false", "aria-controls": "sidebar"}
182 """
183 attrs = AriaAttrs(
184 role=AriaRole.BUTTON,
185 label=label,
186 pressed=pressed,
187 expanded=expanded,
188 controls=controls,
189 haspopup=haspopup,
190 disabled=disabled if disabled else None,
191 )
192 return attrs.to_dict()
195def dialog_aria(
196 label: str,
197 describedby: str | None = None,
198 modal: bool = True,
199) -> dict[str, str]:
200 """Return ARIA attributes for a modal or non-modal dialog overlay.
202 Args:
203 label: Accessible name for the dialog, mapped to ``aria-label``. Use
204 a concise title that describes the dialog's purpose, e.g.
205 ``"Confirm deletion"``.
206 describedby: ID of an element that provides a longer description of
207 the dialog's purpose. Maps to ``aria-describedby``.
208 modal: When ``True`` (default), adds ``aria-modal="true"`` to signal
209 that background content is inert while the dialog is open.
211 Returns:
212 A ``dict[str, str]`` of HTML attribute names to their string values.
214 Example::
216 attrs = dialog_aria("Delete user", describedby="delete-desc")
217 # {"role": "dialog", "aria-label": "Delete user",
218 # "aria-describedby": "delete-desc", "aria-modal": "true"}
219 """
220 attrs = AriaAttrs(
221 role=AriaRole.DIALOG,
222 label=label,
223 describedby=describedby,
224 )
225 result = attrs.to_dict()
226 if modal:
227 result["aria-modal"] = "true"
228 return result
231def search_aria(
232 label: str = "Search",
233 controls: str | None = None,
234) -> dict[str, str]:
235 """Return ARIA attributes for a search input field.
237 Args:
238 label: Accessible name for the search field. Defaults to
239 ``"Search"``.
240 controls: ID of the live region or results container that this input
241 updates. Maps to ``aria-controls`` and helps screen readers
242 announce that results are available.
244 Returns:
245 A ``dict[str, str]`` of HTML attribute names to their string values.
246 """
247 attrs = AriaAttrs(
248 role=AriaRole.SEARCHBOX,
249 label=label,
250 controls=controls,
251 )
252 return attrs.to_dict()
255def live_region_aria(
256 politeness: AriaLive = AriaLive.POLITE,
257 atomic: bool = True,
258) -> dict[str, str]:
259 """Return ARIA attributes for a live region container.
261 Live regions allow assistive technologies to announce dynamic content
262 changes without requiring user focus. Use :func:`announce` for
263 one-shot screen-reader announcements; use this function when you need
264 to annotate a persistent container.
266 Args:
267 politeness: Interrupt behaviour for the announcement. Use
268 :attr:`AriaLive.POLITE` (default) to wait until the user is
269 idle, or :attr:`AriaLive.ASSERTIVE` for time-sensitive alerts.
270 atomic: When ``True`` (default), the entire region is re-read on
271 every change rather than just the changed nodes.
273 Returns:
274 A ``dict[str, str]`` of HTML attribute names to their string values.
275 """
276 attrs = AriaAttrs(
277 live=politeness,
278 atomic=atomic,
279 )
280 return attrs.to_dict()
283def tab_aria(
284 label: str,
285 selected: bool = False,
286 controls: str | None = None,
287) -> dict[str, str]:
288 """Return ARIA attributes for a tab button within a tab list.
290 The tab element must be a child of an element with ``role="tablist"``
291 and must reference its associated panel via *controls*.
293 Args:
294 label: Accessible name for the tab, mapped to ``aria-label``.
295 selected: Whether this tab is currently the active tab. Maps to
296 ``aria-selected``.
297 controls: ID of the ``tabpanel`` element this tab reveals. Maps
298 to ``aria-controls``.
300 Returns:
301 A ``dict[str, str]`` of HTML attribute names to their string values.
302 """
303 attrs = AriaAttrs(
304 role=AriaRole.TAB,
305 label=label,
306 selected=selected,
307 controls=controls,
308 )
309 return attrs.to_dict()
312def tabpanel_aria(
313 labelledby: str,
314 hidden: bool = False,
315) -> dict[str, str]:
316 """Return ARIA attributes for a tab panel content area.
318 The panel must be associated with its controlling tab via *labelledby*.
320 Args:
321 labelledby: ID of the ``tab`` element that controls this panel. Maps
322 to ``aria-labelledby``.
323 hidden: When ``True``, adds ``aria-hidden="true"`` to hide the panel
324 from assistive technologies when its tab is not selected.
326 Returns:
327 A ``dict[str, str]`` of HTML attribute names to their string values.
328 """
329 attrs = AriaAttrs(
330 role=AriaRole.TABPANEL,
331 labelledby=labelledby,
332 hidden=hidden if hidden else None,
333 )
334 return attrs.to_dict()
337# Screen reader announcements
340def announce(
341 message: str,
342 priority: AriaLive = AriaLive.POLITE,
343 atomic: bool = True,
344) -> str:
345 """
346 Create an invisible live region announcement.
348 This element will be announced by screen readers when inserted
349 into the DOM. Use for dynamic content updates.
351 Args:
352 message: The text to announce
353 priority: POLITE (wait for idle) or ASSERTIVE (immediate)
354 atomic: Whether to announce the entire region or just changes
356 Returns:
357 HTML string for the announcement element
358 """
359 return render_to_string(
360 el(
361 "div",
362 message,
363 class_="sr-only",
364 role="status",
365 **live_region_aria(priority, atomic),
366 ),
367 )
370def announce_table_update(
371 total: int,
372 page: int | None = None,
373 search: str | None = None,
374) -> str:
375 """Create a polite screen-reader announcement for a table data refresh.
377 Composes a human-readable summary of the current table state and emits
378 it as a ``POLITE`` live-region element via :func:`announce`.
380 Args:
381 total: Total number of items currently displayed or matching the
382 current filter.
383 page: Current page number when the table is paginated. Omit (or
384 pass ``None``) for unpaginated tables.
385 search: Active search / filter text. When provided, the message
386 includes ``"filtered by '<term>'"``.
388 Returns:
389 An HTML string containing the invisible announcement element.
390 """
391 parts = [f"{total} items"]
392 if page:
393 parts.append(f"page {page}")
394 if search:
395 parts.append(f"filtered by '{search}'")
397 message = ", ".join(parts)
398 return announce(message)
401def announce_selection_change(
402 count: int,
403 action: str = "selected",
404) -> str:
405 """Create a polite screen-reader announcement for a selection state change.
407 Args:
408 count: Number of items currently in the selection.
409 action: Past-tense verb describing the selection action, e.g.
410 ``"selected"`` (default) or ``"deselected"``.
412 Returns:
413 An HTML string containing the invisible announcement element.
414 """
415 if count == 0:
416 message = "No items selected"
417 elif count == 1:
418 message = f"1 item {action}"
419 else:
420 message = f"{count} items {action}"
422 return announce(message)
425def announce_action_complete(
426 action: str,
427 success: bool = True,
428) -> str:
429 """Create an assertive screen-reader announcement for an action outcome.
431 Uses ``ASSERTIVE`` politeness so the result is announced immediately,
432 interrupting any in-progress speech. Suitable for confirming or
433 reporting the failure of a user-initiated action.
435 Args:
436 action: Human-readable description of the action, e.g.
437 ``"User deleted"`` or ``"Export"``.
438 success: When ``True`` (default), appends ``"completed successfully"``;
439 when ``False``, appends ``"failed"``.
441 Returns:
442 An HTML string containing the invisible announcement element.
443 """
444 status = "completed successfully" if success else "failed"
445 return announce(f"{action} {status}", priority=AriaLive.ASSERTIVE)
448def SkipLink(
449 target_id: str = "main-content", label: str = "Skip to main content"
450) -> str:
451 """Return an HTML skip navigation link for accessibility.
453 Args:
454 target_id: The ID of the target element to skip to.
455 label: The link text for the skip link.
457 Returns:
458 An HTML anchor tag with sr-only CSS class.
459 """
460 return f'<a href="#{target_id}" class="sr-only">{label}</a>'
463def keyboard_navigation_script() -> str:
464 """Return a script tag with keyboard navigation helpers.
466 Returns:
467 An HTML script tag with keyboard navigation JavaScript.
468 """
469 return '<script>document.addEventListener("keydown",function(e){if(e.key==="ArrowDown"){e.preventDefault();}if(e.key==="ArrowUp"){e.preventDefault();}if(e.key==="Escape"){e.preventDefault();}if(e.key==="/"){document.body.classList.add("keyboard-nav");}});</script>'
472__all__ = [
473 "SkipLink",
474 "announce",
475 "announce_action_complete",
476 "announce_selection_change",
477 "announce_table_update",
478 "button_aria",
479 "cell_aria",
480 "dialog_aria",
481 "header_aria",
482 "keyboard_navigation_script",
483 "live_region_aria",
484 "row_aria",
485 "search_aria",
486 "tab_aria",
487 "table_aria",
488 "tabpanel_aria",
489]