Accessibility in R applications: {shiny}
This is part two of our two part series
- Part 1: The importance of web accessibility standards
- Part 2: Accessibility in R applications: {shiny} (this post)
Web applications that are Web Content Accessibility Guidelines (WCAG) compliant are becoming an increasingly prominent part of my role as a data scientist as the importance of ensuring that data products are available to all takes a more central focus. This is particularly true in the case of building solutions for public sector organisations in the UK as they are under a legal obligation to meet certain accessibility requirements.
{shiny} has, for some time now, been a leading route that statisticians, analysts and data scientists might take to provide a web based application as a graphical user interface to data manipulation, graphical and statistical tooling that may otherwise only be easily accessible to R programmers.
At Jumping Rivers we were recently tasked with taking a prototype product, which we initially helped to develop in {shiny}, to a public facing production environment for a public sector client. This blog post highlights some of the thoughts that arose throughout the scoping stage of that project when assessing {shiny} as a suitable candidate for the final solution.
Accessibility and {shiny}
The good
The great thing about {shiny} is that it allows data practitioners a relatively simple, quick approach to providing an intuitive user interface to their R code via a web application. So effective is {shiny} at this job that it can be done with little to no traditional web development knowledge on the part of the developer. {shiny} and associated packages provide collections of R functions that return HTML, CSS and JavaScript which is then shipped to a browser. The variety of packages giving trivial access to styled front end components and widgets is already large and constantly growing. What this means is that R programmers can achieve a huge amount in the way of building complex, visually attractive web applications without needing to care very much about the underlying generated content that is interpreted by the browser.
Need a tidy menu to drive navigation around your application? {shinydashboard} is a fine choice. If you want an attractive table to display data, that also facilitates download, sorting, pagination plus numerous other “bolt-ons” then many R users will point you in the direction of {DT}. {plotly} has you covered for interactive charts, again allowing you to do things like download snapshots from your plots for use elsewhere.
This sort of technology is absolutely fantastic for prototyping products. The feedback loop through iteration from initial idea, manipulation and modelling code, rough design and layout to usable and deployable application can be phenomenally fast. That is not to say that shiny is completely inappropriate beyond the prototyping stage, just that, certainly in my opinion, this is absolutely one of its biggest strengths.
The bad
If it is good that shiny allows data and statistics experts to create web applications without any real knowledge of front end technology then it is almost certainly also bad that shiny allows data and statistics experts to create web applications without any real knowledge of front end technology. To my mind, its big strength is also a weakness. Much or all of the browser interpreted content is generated for you. This is particularly prominent when considering WCAG compliance.
Let’s take a look at how some of this problem is manifested:
Consider the following snippet of code which I suspect is something reflective of a large number of shiny applications given the popularity of the package.
library("shinydashboard")
ui = shinydashboardPlus::dashboardPage(
skin = "purple",
header = dashboardHeader(title = "My App"),
sidebar = dashboardSidebar(
sidebarMenu(
id = "sidebarMenu",
menuItem("Home page", tabName = "Home")
)
),
body = dashboardBody(
id = "dashboardBody",
tabItems(
tabItem(tabName = "Home", "Hello Dashboard")
)
)
)
# Note that I would typically namespace all function calls
# and encourage others to do the same, however for the
# purpose of a blog post, loading the packages via `library`
# may make it a little easier to read on small screen formats.
If we were to start a shiny app in the usual sort of way
server = function(input, output, session){
# empty server function
# not important for discussion but necessary
# to launch an application.
}
shiny::shinyApp(ui, server)
this gives the not entirely unattractive UI below (subjective I know).
It does so by generating the following markup as the output of the R code which is shipped off to the browser to be rendered.
<body data-scrollToTop="0" class="hold-transition skin-purple" data-skin="purple" style="min-height: 611px;">
<div class="wrapper">
<header class="main-header">
<span class="logo">My App</span>
<nav class="navbar navbar-static-top" role="navigation">
<span style="display:none;">
<i class="fa-solid fa-bars" role="presentation" aria-label="bars icon"></i>
</span>
<a href="#" class="sidebar-toggle" data-toggle="offcanvas" role="button">
<span class="sr-only">Toggle navigation</span>
</a>
<div class="navbar-custom-menu">
<ul class="nav navbar-nav"></ul>
</div>
</nav>
</header>
<aside id="sidebarCollapsed" class="main-sidebar" data-collapsed="false">
<section id="sidebarItemExpanded" class="sidebar">
<ul class="sidebar-menu">
<li>
<a href="#shiny-tab-Home" data-toggle="tab" data-value="Home">
<span>Home page</span>
</a>
</li>
<div id="sidebarMenu" class="sidebarMenuSelectedTabItem" data-value="null"></div>
</ul>
</section>
</aside>
<div class="content-wrapper">
<section class="content" id="dashboardBody">
<div class="tab-content">
<div role="tabpanel" class="tab-pane" id="shiny-tab-Home">Hello Dashboard</div>
</div>
</section>
</div>
</div>
</body>
So what’s the problem? Well, even though our application has almost zero content to it, it would fail even the most basic of accessibility tests. Chrome based browsers have a tool, Lighthouse, accessible from the developer console in the browser, which can provide a report on accessibility for a web page. This is by no means a comprehensive WCAG compliance assessment, but seems like a reasonable first hurdle to get over.
A Lighthouse report, whilst reminding us that
Only a subset of accessibility issues can be automatically detected so manual testing is also encouraged
gives the following on our “app”:
- Document does not have a
<title>
element <html>
element does not have a [lang] attribute- Lists do not only contain
<li>
elements and script supporting elements - [aria-*] attributes do not match their roles (ARIA is a set of attributes that define ways to make web content and web applications more accessible to people with disabilities)
The ugly
On the assumption I have the above app and want to stick with {shiny} and {shinydashboard} what can I do to solve these flagged issues?
Document does not have a <title> element
The issue: The page should have a
<title>My title</title>
element within the<head>
Accessibility problem: The title gives users of screen readers and other assistive technologies an overview of the page, it is the first text that an assistive technology announces. The title is also important for search engine users to determine whether a page is relevant.
A solution:
shinydashboardPlus::dashboardPage( skin = "purple", header = dashboardHeader(title = "My App"), sidebar = dashboardSidebar( sidebarMenu( id = "sidebarMenu", menuItem("Home page", tabName = "Home") ) ), body = dashboardBody( tags$head(tags$title("My app")), # modification id = "dashboardBody", tabItems( tabItem(tabName = "Home", "Hello Dashboard") ) ) )
You can solve the title issue with
title = "My app"
in thedashboardPage()
function here, but that wouldn’t be applicable to all cases.tags$head(tags$title())
will always add the title tag to the head of the web page.
<html> element does not have a [lang] attribute
The issue: The
<html>
element of the page should have an attribute specifying the language of the content, e.g<html lang='en'> ... </html>
Accessibility problem: Screen readers use a different sound library for each language they support to ensure correct pronunciation. If a page doesn’t specify a language, a screen reader assumes the page is in the default language that the user chose when setting up the screen reader, often making it impossible to understand the content.
A solution:
This has been noted on github for which a
lang
parameter was added toshiny::*Page()
but doesn’t solve the problem for our dashboard. A proposed more general fix would beshinydashboardPlus::dashboardPage( skin = "purple", header = dashboardHeader(title = "My App"), sidebar = dashboardSidebar( sidebarMenu( id = "sidebarMenu", menuItem("Home page", tabName = "Home") ) ), body = dashboardBody( tags$html(lang = "en"), # modification id = "dashboardBody", tabItems( tabItem(tabName = "Home", "Hello Dashboard") ) ) )
but it is also noted that local use of a lang attribute like this should be limited to only when there is a language change, to force screen readers to switch speech synthesizers. So this solution is not really ideal. It is also absolutely not clear how to do this properly. I had to inspect the file changes of the commits merged for the above noted issue to find that when running a
shiny::shinyApp
the render function checks for a lang attribute. So this issue should really be solved withui = shinydashboardPlus::dashboardPage(...) attr(ui, "lang") = "en"
Lists do not only contain <li> elements and script supporting elements
The issue:
<ul>
and<ol>
list elements, should only contain<li>
list items or<script>
elements within them. Here we have a<div>
element inside our<ul>
Accessibility problem: Screen readers and other assistive technologies depend on lists being structured properly to keep users informed of content within the lists.
A solution:
This is where it starts to get a bit more painful…
The list elements referred to are those in the menu, and the problem is the
<div>
element<ul class="sidebar-menu"> <li> <a href="#shiny-tab-Home" data-toggle="tab" data-value="Home"> <span>Home page</span> </a> </li> <div id="sidebarMenu" class="sidebarMenuSelectedTabItem" data-value="null"></div> </ul>
which is added to the html to be returned in the
shinydashboard::sidebarMenu()
function. As far as I can see, we have two possible strategies here, neither of which is nice.- Manipulate the object returned by {shinydashboard}. Here we
could remove the rogue
<div>
element pretty easily
x = sidebarMenu( id = "sidebarMenu", menuItem("Home page", tabName = "Home") ) x$children[[length(x$children)]] = NULL shinydashboardPlus::dashboardPage( skin = "purple", header = dashboardHeader(title = "My App"), sidebar = dashboardSidebar(x), body = dashboardBody( id = "dashboardBody", tabItems( tabItem( tabName = "Home", "Hello Dashboard" ) ) ) )
which, whilst removing the Lighthouse reported issue, unfortunately gives us another, less easy to immediately see problem, which is that the shiny input binding for the tab that is currently in view is now broken and always returns NULL. So we rethink and come up with
x = sidebarMenu( id = "sidebarMenu", menuItem("Home page", tabName = "Home") ) tab_input = x$children[[length(x$children)]] x$children[[length(x$children)]] = NULL real_menu = tagList(x, tab_input) shinydashboardPlus::dashboardPage( skin = "purple", header = dashboardHeader(title = "My App"), sidebar = dashboardSidebar(real_menu), body = dashboardBody( id = "dashboardBody", tabItems( tabItem( tabName = "Home", "Hello Dashboard" ) ) ) )
which doesn’t work either. This time the input on start-up of the application fires twice instead of once, and it’s not immediately clear why that is the case. My imaginary application needs this feature so we implement some hack for it and wrap it up in our own function so that it can be reused (it’s not ideal but it works… sort of, it definitely breaks with shiny modules though.)
accessible_menu = function(bad_menu) { tab_input = tags$script( " function customMenuHandleClick(e) { let n = $(e.target).parents('ul.sidebar-menu').find('li.active:not(.treeview)').children('a')[0].dataset.value; doSomethingWith(n); } function doSomethingWith(val) { Shiny.setInputValue('sidebarMenu', val); } $(document).ready( function() { $('ul.sidebar-menu li').click(customMenuHandleClick) }); " ) bad_menu$children[[length(bad_menu$children)]] = NULL real_menu = tagList(bad_menu, tab_input) real_menu } x = sidebarMenu( id = "sidebarMenu", menuItem("Home page", tabName = "Home") ) shinydashboardPlus::dashboardPage( skin = "purple", header = dashboardHeader(title = "My App"), sidebar = dashboardSidebar(accessible_menu(x)), body = dashboardBody( tags$html(lang = "en"), id = "dashboardBody", tabItems( tabItem(tabName = "Home", "Hello Dashboard") ) ) )
- Use or develop a different navigation structure for the app
- Manipulate the object returned by {shinydashboard}. Here we
could remove the rogue
[aria-*] attributes do not match their roles
The issue: Each ARIA role supports a specific subset of aria-* attributes.
Accessibility problem: Users of screen readers and other assistive technologies need information about the behavior and purpose of controls on your web page. Built-in HTML controls like buttons and radio groups come with that information built in. For custom controls you create, however, you must provide the information with ARIA roles and attributes.
A solution?:
This is caused by the
<a>
tag in the<li>
for the menu item. This is potentially somewhat confusing because the generated HTML when viewing the output of the relevant R code is<a href="#shiny-tab-Home" data-toggle="tab" data-value="Home"> <span>Home page</span> </a>
but they are added as part of the JavaScript bundle that is given to the browser that controls other behaviour of the {shinydashboard} library. After launching the application it becomes
<a href="#shiny-tab-Home" data-toggle="tab" data-value="Home" aria-expanded="true" tabindex="0" aria-selected="true"> <span>Home page</span> </a>
So we are now at a state where even though we start to patch functions generating the UI code, things are happening outside of my direct control which make it extremely difficult to force this package to comply with WCAG. And we are completely ignoring all the things that Lighthouse doesn’t pick up on. To name some:
- The header section includes an empty list
(
<ul class="nav navbar-nav"></ul>
). - The “Toggle Navigation” component is correctly labelled, and is correctly exposed as a button. However, it is missing the aria-expanded attribute. Each of the navigation menu items is exposed as a link but, in reality, these are tabs (as they don’t direct the user to other pages - instead, only the main content section changes).
- The container for the main content section is unnecessarily
focusable
, astabindex="0"
is applied to the related<div>
element (<div id="shiny-tab-Home" role="tabpanel" tabindex="0">
). Only functional/operable content should be focusable using the keyboard. - Navigation menu content is still readable by screen readers even when the related content is in a collapsed (visibly hidden) state.
So scrap {shiny}?
Is {shiny} a terrible solution when wanting to build an accessible web app then? Well not necessarily, at the end of the day, all {shiny} does is wrap front end content in R functions. You can still write R functions that will generate WCAG compliant HTML. But… and I think it is quite a big but, making a {shiny} application WCAG compliant requires a bit more thought and attention, and almost certainly means not using all your favourite libraries. It was ignored in the previous section but {DT} and {plotly}, both mentioned as great packages for common {shiny} app components, also do not give WCAG compliant markup. {plotly} in particular is very problematic in this arena, still one of my favourite plotting solutions for R, but not amenable to an accessible application. In short, you will have to roll your own a bit more.
There are tools to help you assess your application. Lighthouse in the browser was used in the above discussion, there are other tools like Koa11y for generating reports which I find give more info and there is a {shinya11y} R package which aims to help specifically with {shiny}. Having said that none of these tools are perfect.
How does this story end?
In summary, it is entirely possible to create fully accessible {shiny} applications, however I think there is a lot of work to be done by developers of packages for {shiny} to ease the burden somewhat as at present a lot of my favourite packages leave me with too much hacking to do to solve the problem. For the particular project referenced in the opening remarks, the requirement to be WCAG compliant plus some additional constraints meant that an alternative solution based on {plumber} and a separate front end was developed. In my initial report I remarked to the client that a {shiny} solution could be developed and I maintain that view now, however I am a little bit happy that we opted for an alternative. I do love {shiny} and will continue to use it a lot, but it is not the only solution we have available to us and until it becomes a little easier to create accessible applications with some complexity to them I can’t strongly recommend it for every application.