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

1"""ARIA attribute helper functions for accessibility utilities.""" 

2 

3from __future__ import annotations 

4 

5from lexigram.ui.accessibility._aria_types import AriaAttrs, AriaLive, AriaRole 

6from lexigram.ui.core.base import el, render_to_string 

7 

8# Factory functions for common ARIA patterns 

9 

10 

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). 

18 

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. 

22 

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. 

33 

34 Returns: 

35 A ``dict[str, str]`` of HTML attribute names to their string values, 

36 ready to be spread onto the element. 

37 

38 Example:: 

39 

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() 

51 

52 

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. 

59 

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. 

67 

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() 

78 

79 

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. 

85 

86 Uses the ``gridcell`` role, which is appropriate for cells inside a 

87 ``grid``-role container that supports keyboard interaction. 

88 

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. 

95 

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() 

105 

106 

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. 

113 

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``. 

121 

122 Returns: 

123 A ``dict[str, str]`` of HTML attribute names to their string values. 

124 

125 Example:: 

126 

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" 

138 

139 attrs = AriaAttrs( 

140 role=AriaRole.COLUMNHEADER, 

141 label=label, 

142 sort=sort, 

143 ) 

144 return attrs.to_dict() 

145 

146 

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. 

156 

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. 

160 

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"``. 

173 

174 Returns: 

175 A ``dict[str, str]`` of HTML attribute names to their string values. 

176 

177 Example:: 

178 

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() 

193 

194 

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. 

201 

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. 

210 

211 Returns: 

212 A ``dict[str, str]`` of HTML attribute names to their string values. 

213 

214 Example:: 

215 

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 

229 

230 

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. 

236 

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. 

243 

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() 

253 

254 

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. 

260 

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. 

265 

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. 

272 

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() 

281 

282 

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. 

289 

290 The tab element must be a child of an element with ``role="tablist"`` 

291 and must reference its associated panel via *controls*. 

292 

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``. 

299 

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() 

310 

311 

312def tabpanel_aria( 

313 labelledby: str, 

314 hidden: bool = False, 

315) -> dict[str, str]: 

316 """Return ARIA attributes for a tab panel content area. 

317 

318 The panel must be associated with its controlling tab via *labelledby*. 

319 

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. 

325 

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() 

335 

336 

337# Screen reader announcements 

338 

339 

340def announce( 

341 message: str, 

342 priority: AriaLive = AriaLive.POLITE, 

343 atomic: bool = True, 

344) -> str: 

345 """ 

346 Create an invisible live region announcement. 

347 

348 This element will be announced by screen readers when inserted 

349 into the DOM. Use for dynamic content updates. 

350 

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 

355 

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 ) 

368 

369 

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. 

376 

377 Composes a human-readable summary of the current table state and emits 

378 it as a ``POLITE`` live-region element via :func:`announce`. 

379 

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>'"``. 

387 

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}'") 

396 

397 message = ", ".join(parts) 

398 return announce(message) 

399 

400 

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. 

406 

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"``. 

411 

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}" 

421 

422 return announce(message) 

423 

424 

425def announce_action_complete( 

426 action: str, 

427 success: bool = True, 

428) -> str: 

429 """Create an assertive screen-reader announcement for an action outcome. 

430 

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. 

434 

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"``. 

440 

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) 

446 

447 

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. 

452 

453 Args: 

454 target_id: The ID of the target element to skip to. 

455 label: The link text for the skip link. 

456 

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>' 

461 

462 

463def keyboard_navigation_script() -> str: 

464 """Return a script tag with keyboard navigation helpers. 

465 

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>' 

470 

471 

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]