Skip to content

Commit 42d0672

Browse files
Add a title attribute to rendered iframes for accessibility
Accessibility audits flag the iframe that branca renders in notebooks with "frames must have an accessible name" (WCAG 2.1), because the iframe has no title attribute (#213). Emit a title attribute when one is available: - Figure reuses its existing title, so Figure(title="...") now names both the document <title> and the iframe. - IFrame gains a title parameter. When no title is set the output is byte-for-byte unchanged, so existing maps are unaffected; the title is HTML-escaped so a value containing a quote cannot break out of the attribute. Builds on #235 (both touch the same iframe templates); the net new change here is the title attribute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 4e8d00f commit 42d0672

2 files changed

Lines changed: 60 additions & 9 deletions

File tree

‎branca/element.py‎

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,8 @@ class Figure(Element):
328328
A percentage defining the aspect ratio of the Figure.
329329
It will be ignored if height is not None.
330330
title : str, default None
331-
Figure title.
331+
Figure title. Also used as the ``title`` attribute (accessible
332+
name) of the iframe when the Figure is displayed in a notebook.
332333
figsize : tuple of two int, default None
333334
If you're a matplotlib addict, you can overwrite width and
334335
height. Values will be converted into pixels in using 60 dpi.
@@ -412,24 +413,31 @@ def render(self, **kwargs) -> str:
412413
def _repr_html_(self, **kwargs) -> str:
413414
"""Displays the Figure in a Jupyter notebook."""
414415
html = escape(self.render(**kwargs))
416+
# Give the iframe an accessible name when a title is set, so it is
417+
# not flagged by "frames must have an accessible name" audits.
418+
title_attr = f' title="{escape(self.title)}"' if self.title else ""
415419
if self.height is None:
416420
iframe = (
417421
'<div style="width:{width};">'
418422
'<div style="position:relative;width:100%;height:0;padding-bottom:{ratio};">' # noqa
419423
'<span style="color:#565656">Make this Notebook Trusted to load map: File -> Trust Notebook</span>' # noqa
420-
'<iframe srcdoc="{html}" style="position:absolute;width:100%;height:100%;left:0;top:0;' # noqa
424+
'<iframe srcdoc="{html}"{title_attr} style="position:absolute;width:100%;height:100%;left:0;top:0;' # noqa
421425
'border:none !important;" '
422426
"allowfullscreen webkitallowfullscreen mozallowfullscreen>"
423427
"</iframe>"
424428
"</div></div>"
425-
).format(html=html, width=self.width, ratio=self.ratio)
429+
).format(
430+
html=html, width=self.width, ratio=self.ratio, title_attr=title_attr
431+
)
426432
else:
427433
iframe = (
428-
'<iframe srcdoc="{html}" width="{width}" height="{height}" '
434+
'<iframe srcdoc="{html}"{title_attr} width="{width}" height="{height}" '
429435
'style="border:none !important;" '
430436
"allowfullscreen webkitallowfullscreen mozallowfullscreen>"
431437
"</iframe>"
432-
).format(html=html, width=self.width, height=self.height)
438+
).format(
439+
html=html, width=self.width, height=self.height, title_attr=title_attr
440+
)
433441
return iframe
434442

435443
def add_subplot(self, x: int, y: int, n: int, margin: float = 0.05) -> "Div":
@@ -640,6 +648,10 @@ class IFrame(Element):
640648
height. Values will be converted into pixels in using 60 dpi.
641649
For example figsize=(10, 5) will result in
642650
width="600px", height="300px".
651+
title : str, default None
652+
Value for the iframe's ``title`` attribute, used as the frame's
653+
accessible name. Set it to satisfy accessibility audits that
654+
require every frame to have an accessible name.
643655
"""
644656

645657
def __init__(
@@ -649,10 +661,12 @@ def __init__(
649661
height: Optional[str] = None,
650662
ratio: str = "60%",
651663
figsize: Optional[Tuple[int, int]] = None,
664+
title: Optional[str] = None,
652665
):
653666
super().__init__()
654667
self._name = "IFrame"
655668

669+
self.title = title
656670
self.width = width
657671
self.height = height
658672
self.ratio = ratio
@@ -671,21 +685,26 @@ def render(self, **kwargs) -> str:
671685
html = "data:text/html;charset=utf-8;base64," + base64.b64encode(
672686
html.encode("utf8"),
673687
).decode("utf8")
688+
title_attr = f' title="{escape(self.title)}"' if self.title else ""
674689

675690
if self.height is None:
676691
iframe = (
677692
'<div style="width:{width};">'
678693
'<div style="position:relative;width:100%;height:0;padding-bottom:{ratio};">' # noqa
679-
'<iframe src="{html}" style="position:absolute;width:100%;height:100%;left:0;top:0;' # noqa
694+
'<iframe src="{html}"{title_attr} style="position:absolute;width:100%;height:100%;left:0;top:0;' # noqa
680695
'border:none !important;">'
681696
"</iframe>"
682697
"</div></div>"
683-
).format(html=html, width=self.width, ratio=self.ratio)
698+
).format(
699+
html=html, width=self.width, ratio=self.ratio, title_attr=title_attr
700+
)
684701
else:
685702
iframe = (
686-
'<iframe src="{html}" width="{width}" style="border:none !important;" '
703+
'<iframe src="{html}"{title_attr} width="{width}" style="border:none !important;" '
687704
'height="{height}"></iframe>'
688-
).format(html=html, width=self.width, height=self.height)
705+
).format(
706+
html=html, width=self.width, height=self.height, title_attr=title_attr
707+
)
689708
return iframe
690709

691710

‎tests/test_element.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,35 @@ def test_figure_repr_html_fullscreen_attrs_with_height():
4242
assert name in attrs, f"{name!r} missing; got {sorted(attrs)}"
4343
assert attrs["height"] == "400px"
4444
assert attrs["width"] == "100%"
45+
46+
47+
def test_figure_iframe_title_absent_by_default():
48+
for height in (None, "400px"):
49+
attrs = _iframe_attrs(elem.Figure(height=height)._repr_html_())
50+
assert "title" not in attrs
51+
52+
53+
def test_figure_iframe_title_set():
54+
for height in (None, "400px"):
55+
attrs = _iframe_attrs(elem.Figure(height=height, title="My Map")._repr_html_())
56+
assert attrs["title"] == "My Map"
57+
58+
59+
def test_figure_iframe_title_is_escaped():
60+
# A title with a double quote must not break out of the attribute.
61+
attrs = _iframe_attrs(elem.Figure(title='a "b" <c>')._repr_html_())
62+
assert attrs["title"] == 'a "b" <c>'
63+
64+
65+
def test_iframe_title_absent_by_default():
66+
for height in (None, "300px"):
67+
attrs = _iframe_attrs(elem.IFrame("<p>x</p>", height=height).render())
68+
assert "title" not in attrs
69+
70+
71+
def test_iframe_title_set():
72+
for height in (None, "300px"):
73+
attrs = _iframe_attrs(
74+
elem.IFrame("<p>x</p>", height=height, title="Popup").render()
75+
)
76+
assert attrs["title"] == "Popup"

0 commit comments

Comments
 (0)