Skip to content

Commit 0fe8d36

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 0fe8d36

2 files changed

Lines changed: 72 additions & 9 deletions

File tree

‎branca/element.py‎

Lines changed: 40 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,37 @@ 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,
431+
width=self.width,
432+
ratio=self.ratio,
433+
title_attr=title_attr,
434+
)
426435
else:
427436
iframe = (
428-
'<iframe srcdoc="{html}" width="{width}" height="{height}" '
437+
'<iframe srcdoc="{html}"{title_attr} width="{width}" height="{height}" '
429438
'style="border:none !important;" '
430439
"allowfullscreen webkitallowfullscreen mozallowfullscreen>"
431440
"</iframe>"
432-
).format(html=html, width=self.width, height=self.height)
441+
).format(
442+
html=html,
443+
width=self.width,
444+
height=self.height,
445+
title_attr=title_attr,
446+
)
433447
return iframe
434448

435449
def add_subplot(self, x: int, y: int, n: int, margin: float = 0.05) -> "Div":
@@ -640,6 +654,10 @@ class IFrame(Element):
640654
height. Values will be converted into pixels in using 60 dpi.
641655
For example figsize=(10, 5) will result in
642656
width="600px", height="300px".
657+
title : str, default None
658+
Value for the iframe's ``title`` attribute, used as the frame's
659+
accessible name. Set it to satisfy accessibility audits that
660+
require every frame to have an accessible name.
643661
"""
644662

645663
def __init__(
@@ -649,10 +667,12 @@ def __init__(
649667
height: Optional[str] = None,
650668
ratio: str = "60%",
651669
figsize: Optional[Tuple[int, int]] = None,
670+
title: Optional[str] = None,
652671
):
653672
super().__init__()
654673
self._name = "IFrame"
655674

675+
self.title = title
656676
self.width = width
657677
self.height = height
658678
self.ratio = ratio
@@ -671,21 +691,32 @@ def render(self, **kwargs) -> str:
671691
html = "data:text/html;charset=utf-8;base64," + base64.b64encode(
672692
html.encode("utf8"),
673693
).decode("utf8")
694+
title_attr = f' title="{escape(self.title)}"' if self.title else ""
674695

675696
if self.height is None:
676697
iframe = (
677698
'<div style="width:{width};">'
678699
'<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
700+
'<iframe src="{html}"{title_attr} style="position:absolute;width:100%;height:100%;left:0;top:0;' # noqa
680701
'border:none !important;">'
681702
"</iframe>"
682703
"</div></div>"
683-
).format(html=html, width=self.width, ratio=self.ratio)
704+
).format(
705+
html=html,
706+
width=self.width,
707+
ratio=self.ratio,
708+
title_attr=title_attr,
709+
)
684710
else:
685711
iframe = (
686-
'<iframe src="{html}" width="{width}" style="border:none !important;" '
712+
'<iframe src="{html}"{title_attr} width="{width}" style="border:none !important;" '
687713
'height="{height}"></iframe>'
688-
).format(html=html, width=self.width, height=self.height)
714+
).format(
715+
html=html,
716+
width=self.width,
717+
height=self.height,
718+
title_attr=title_attr,
719+
)
689720
return iframe
690721

691722

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