Accessibility in R applications: {shiny}

[This article was first published on The Jumping Rivers Blog, and kindly contributed to R-bloggers]. (You can report issue about the content on this page here)
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.

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.


Do you require help building a Shiny app? Would you like someone to take over the maintenance burden? If so, check out our Shiny and Dash services.


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

“hello dashboard shiny ui image”

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 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 element</h4> <ul> <li> <p><strong>The issue:</strong> The page should have a <code><title>My title</title></code> element within the <code><head></code></p> </li> <li> <p><strong>Accessibility problem:</strong> 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.</p> </li> <li> <p><strong>A solution:</strong></p> <pre>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") ) ) ) </pre><p>You can solve the title issue with <code>title = "My app"</code> in the <code>dashboardPage()</code> function here, but that wouldn’t be applicable to all cases. <code>tags$head(tags$title())</code> will always add the title tag to the head of the web page.</p> </li> </ul> <h4 id="html-element-does-not-have-a-lang-attribute"><html> element does not have a [lang] attribute</h4> <ul> <li> <p><strong>The issue:</strong> The <code><html></code> element of the page should have an attribute specifying the language of the content, e.g <code><html lang='en'> ... </html></code></p> </li> <li> <p><strong>Accessibility problem:</strong> 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.</p> </li> <li> <p><strong>A solution:</strong></p> <p>This has been noted on <a href="https://github.com/rstudio/shiny/issues/2844" rel="nofollow" target="_blank">github</a> for which a <code>lang</code> parameter was added to <code>shiny::*Page()</code> but doesn’t solve the problem for our dashboard. A proposed more general fix would be</p> <pre>shinydashboardPlus::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") ) ) ) </pre><p>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 <code>shiny::shinyApp</code> the render function checks for a lang attribute. So this issue should really be solved with</p> <pre>ui = shinydashboardPlus::dashboardPage(...) attr(ui, "lang") = "en" </pre></li> </ul> <h4 id="lists-do-not-only-contain-li-elements-and-script-supporting-elements">Lists do not only contain <li> elements and script supporting elements</h4> <ul> <li> <p><strong>The issue:</strong> <code><ul></code> and <code><ol></code> list elements, should only contain <code><li></code> list items or <code><script></code> elements within them. Here we have a <code><div></code> element inside our <code><ul></code></p> </li> <li> <p><strong>Accessibility problem:</strong> Screen readers and other assistive technologies depend on lists being structured properly to keep users informed of content within the lists.</p> </li> <li> <p><strong>A solution:</strong></p> <p>This is where it starts to get a bit more painful…</p> <p>The list elements referred to are those in the menu, and the problem is the <code><div></code> element</p> <pre><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> </pre><p>which is added to the html to be returned in the <code>shinydashboard::sidebarMenu()</code> function. As far as I can see, we have two possible strategies here, neither of which is nice.</p> <ol> <li>Manipulate the object returned by {shinydashboard}. Here we could remove the rogue <code><div></code> element pretty easily</li> </ol> <pre>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" ) ) ) ) </pre><p>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</p> <pre>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" ) ) ) ) </pre><p>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.)</p> <pre>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") ) ) ) </pre><ol start="2"> <li>Use or develop a different navigation structure for the app</li> </ol> </li> </ul> <h4 id="aria--attributes-do-not-match-their-roles">[aria-*] attributes do not match their roles</h4> <ul> <li> <p><strong>The issue:</strong> Each ARIA role supports a specific subset of aria-* attributes.</p> </li> <li> <p><strong>Accessibility problem:</strong> 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.</p> </li> <li> <p><strong>A solution?:</strong></p> <p>This is caused by the <code><a></code> tag in the <code><li></code> for the menu item. This is potentially somewhat confusing because the generated HTML when viewing the output of the relevant R code is</p> <pre><a href="#shiny-tab-Home" data-toggle="tab" data-value="Home"> <span>Home page</span> </a> </pre><p>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</p> <pre><a href="#shiny-tab-Home" data-toggle="tab" data-value="Home" aria-expanded="true" tabindex="0" aria-selected="true"> <span>Home page</span> </a> </pre></li> </ul> <p>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:</p> <ul> <li>The header section includes an empty list (<code><ul class="nav navbar-nav"></ul></code>).</li> <li>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).</li> <li>The container for the main content section is unnecessarily <code>focusable</code>, as <code>tabindex="0"</code> is applied to the related <code><div></code> element (<code><div id="shiny-tab-Home" role="tabpanel" tabindex="0"></code>). Only functional/operable content should be focusable using the keyboard.</li> <li>Navigation menu content is still readable by screen readers even when the related content is in a collapsed (visibly hidden) state.</li> </ul> <h2 id="so-scrap-shiny">So scrap {shiny}?</h2> <p>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.</p> <p>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.</p> <h2 id="how-does-this-story-end">How does this story end?</h2> <p>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.</p> <hr /> <a href="https://www.jumpingrivers.com/?utm_source=blog&utm_medium=banner&utm_campaign=2022-accessible-shiny-standards-wcag" rel="nofollow" target="_blank"> <img class="image-center jetpack-lazy-image" alt="Jumping Rivers Logo" src="https://i2.wp.com/www.jumpingrivers.com/blog/img/logo-advert.png?w=578&ssl=1" style="width:300px; class:image-center" data-recalc-dims="1" data-lazy-src="https://i2.wp.com/www.jumpingrivers.com/blog/img/logo-advert.png?w=578&is-pending-load=1#038;ssl=1" srcset="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"><noscript><img class="image-center" alt="Jumping Rivers Logo" src="https://i2.wp.com/www.jumpingrivers.com/blog/img/logo-advert.png?w=578&ssl=1" style="width:300px; class:image-center" data-recalc-dims="1" /></noscript> </a> <p> For updates and revisions to this article, see the <a href = "https://www.jumpingrivers.com/blog/accessible-shiny-standards-wcag/">original post</a> </p> <div id='jp-relatedposts' class='jp-relatedposts' > <h3 class="jp-relatedposts-headline"><em>Related</em></h3> </div><aside class="mashsb-container mashsb-main mashsb-stretched"><div class="mashsb-box"><div class="mashsb-buttons"><a class="mashicon-facebook mash-large mash-center mashsb-noshadow" href="https://www.facebook.com/sharer.php?u=https%3A%2F%2Fwww.r-bloggers.com%2F2022%2F05%2Faccessibility-in-r-applications-shiny%2F" target="_blank" rel="nofollow"><span class="icon"></span><span class="text">Share</span></a><a class="mashicon-twitter mash-large mash-center mashsb-noshadow" href="https://twitter.com/intent/tweet?text=Accessibility%20in%20R%20applications%3A%20%7Bshiny%7D&url=https://www.r-bloggers.com/2022/05/accessibility-in-r-applications-shiny/&via=Rbloggers" target="_blank" rel="nofollow"><span class="icon"></span><span class="text">Tweet</span></a><div class="onoffswitch2 mash-large mashsb-noshadow" style="display:none;"></div></div> </div> <div style="clear:both;"></div></aside> <!-- Share buttons by mashshare.net - Version: 3.8.1--> <div style="border: 1px solid; background: none repeat scroll 0 0 #EDEDED; margin: 1px; font-size: 13px;"> <div style="text-align: center;">To <strong>leave a comment</strong> for the author, please follow the link and comment on their blog: <strong><a href="https://www.jumpingrivers.com/blog/accessible-shiny-standards-wcag/"> The Jumping Rivers Blog</a></strong>.</div> <hr /> <a href="https://www.r-bloggers.com/" rel="nofollow">R-bloggers.com</a> offers <strong><a href="https://feedburner.google.com/fb/a/mailverify?uri=RBloggers" rel="nofollow">daily e-mail updates</a></strong> about <a title="The R Project for Statistical Computing" href="https://www.r-project.org/" rel="nofollow">R</a> news and tutorials about <a title="R tutorials" href="https://www.r-bloggers.com/how-to-learn-r-2/" rel="nofollow">learning R</a> and many other topics. <a title="Data science jobs" href="https://www.r-users.com/" rel="nofollow">Click here if you're looking to post or find an R/data-science job</a>. <hr>Want to share your content on R-bloggers?<a href="https://www.r-bloggers.com/add-your-blog/" rel="nofollow"> click here</a> if you have a blog, or <a href="http://r-posts.com/" rel="nofollow"> here</a> if you don't. </div> </div> </article><nav class="post-navigation clearfix" role="navigation"> <div class="post-nav left"> <a href="https://www.r-bloggers.com/2022/05/automating-and-downloading-google-chrome-images-with-selenium/" rel="prev">← Previous post</a></div> <div class="post-nav right"> </div> </nav> </div> <aside class="mh-sidebar sb-right"> <div id="custom_html-2" class="widget_text sb-widget widget_custom_html"><div class="textwidget custom-html-widget"> <div class="top-search" style="padding-left: 0px;"> <form id="searchform" action="http://www.google.com/cse" target="_blank"> <div> <input type="hidden" name="cx" value="005359090438081006639:paz69t-s8ua" /> <input type="hidden" name="ie" value="UTF-8" /> <input type="text" value="" name="q" id="q" autocomplete="on" style="font-size:16px;" placeholder="Search R-bloggers.." /> <input type="submit" id="searchsubmit2" name="sa" value="Go" style="font-size:16px;" /> </div> </form> </div> <!-- thanks: https://stackoverflow.com/questions/14981575/google-cse-with-a-custom-form https://stackoverflow.com/questions/10363674/change-size-of-text-in-text-input-tag --></div></div><div id="text-6" class="sb-widget widget_text"> <div class="textwidget"><div style="min-height:26px;border:1px solid #ccc;padding:3px;text-align:left; background: none repeat scroll 0 0 #FDEADA;"> <form style="width:202px; float:left;" action="https://r-bloggers.com/phplist/?p=subscribe&id=1" method="post" target="popupwindow"> <input type="text" style="width:110px" onclick="if (this.value == 'Your e-mail here') this.value = '';" value='Your e-mail here' name="email"/> <input type="hidden" value="RBloggers" name="uri"/><input type="hidden" name="loc" value="en_US"/><input type="submit" value="Subscribe" /> </form> <div> <a href="https://feeds.feedburner.com/RBloggers"><img src="https://i1.wp.com/www.r-bloggers.com/wp-content/uploads/2020/07/RBloggers_feedburner_count_2020_07_01-e1593671704447.gif?w=578&ssl=1" style="height:17px;min-width:80px;class:skip-lazy;" alt data-recalc-dims="1" data-lazy-src="https://i1.wp.com/www.r-bloggers.com/wp-content/uploads/2020/07/RBloggers_feedburner_count_2020_07_01-e1593671704447.gif?w=578&is-pending-load=1#038;ssl=1" srcset="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" class=" jetpack-lazy-image"><noscript><img src="https://i1.wp.com/www.r-bloggers.com/wp-content/uploads/2020/07/RBloggers_feedburner_count_2020_07_01-e1593671704447.gif?w=578&ssl=1" style="height:17px;min-width:80px;class:skip-lazy;" alt="" data-recalc-dims="1" /></noscript></a> </div> </div> <br/> <div> <script> function init() { var vidDefer = document.getElementsByTagName('iframe'); for (var i=0; i<vidDefer.length; i++) { if(vidDefer[i].getAttribute('data-src')) { vidDefer[i].setAttribute('src',vidDefer[i].getAttribute('data-src')); } } } window.onload = init; </script> <iframe allowtransparency="true" frameborder="0" scrolling="no" src="" data-src="//platform.twitter.com/widgets/follow_button.html?screen_name=rbloggers&data-show-count" style="width:100%; height:30px;"></iframe> <div id="fb-root"></div> <script async defer crossorigin="anonymous" src="https://connect.facebook.net/en_GB/sdk.js#xfbml=1&version=v7.0&appId=124112670941750&autoLogAppEvents=1" nonce="RysU23SE"></script> <div style="min-height: 154px;" class="fb-page" data-href="https://www.facebook.com/rbloggers/" data-tabs="" data-width="300" data-height="154" data-small-header="true" data-adapt-container-width="true" data-hide-cover="false" data-show-facepile="true"><blockquote cite="https://www.facebook.com/rbloggers/" class="fb-xfbml-parse-ignore"><a href="https://www.facebook.com/rbloggers/">R bloggers Facebook page</a></blockquote></div> <!-- <iframe src="" data-src="//www.facebook.com/plugins/likebox.php?href=http%3A%2F%2Fwww.facebook.com%2Fpages%2FR-bloggers%2F191414254890&width=300&height=155&show_faces=true&colorscheme=light&stream=false&border_color&header=false&appId=400430016676958" scrolling="no" frameborder="0" style="border:none; overflow:hidden; width:100%; height:140px;" allowTransparency="true"></iframe> --> <!-- <br/> <strong>If you are an R blogger yourself</strong> you are invited to <a href="https://www.r-bloggers.com/add-your-blog/">add your own R content feed to this site</a> (<strong>Non-English</strong> R bloggers should add themselves- <a href="https://www.r-bloggers.com/lang/add-your-blog">here</a>) --> </div></div> </div><div id="wppp-3" class="sb-widget widget_wppp"><h4 class="widget-title">Most viewed posts (weekly)</h4> <ul class='wppp_list'> <li><a href='https://www.r-bloggers.com/2022/05/ggblanket-making-beautiful-ggplot2-visualisation-simpler/' title='ggblanket: making beautiful ggplot2 visualisation simpler'>ggblanket: making beautiful ggplot2 visualisation simpler</a></li> <li><a href='https://www.r-bloggers.com/2022/05/best-books-to-learn-r-programming-2/' title='Best Books to Learn R Programming'>Best Books to Learn R Programming</a></li> <li><a href='https://www.r-bloggers.com/2022/05/software-development-resources-for-data-scientists/' title='Software Development Resources for Data Scientists'>Software Development Resources for Data Scientists</a></li> <li><a href='https://www.r-bloggers.com/2022/01/how-to-install-and-update-r-and-rstudio/' title='How to install (and update!) R and RStudio'>How to install (and update!) R and RStudio</a></li> <li><a href='https://www.r-bloggers.com/2016/11/5-ways-to-subset-a-data-frame-in-r/' title='5 Ways to Subset a Data Frame in R'>5 Ways to Subset a Data Frame in R</a></li> <li><a href='https://www.r-bloggers.com/2022/05/how-to-make-a-rounded-corner-bar-plot-in-r/' title='How to make a rounded corner bar plot in R?'>How to make a rounded corner bar plot in R?</a></li> <li><a href='https://www.r-bloggers.com/2021/11/calculate-confidence-intervals-in-r/' title='Calculate Confidence Intervals in R'>Calculate Confidence Intervals in R</a></li> </ul> </div><div id="text-18" class="sb-widget widget_text"><h4 class="widget-title">Sponsors</h4> <div class="textwidget"><div style="min-height: 2055px;"> <script data-cfasync="false" type="text/javascript"> // https://support.cloudflare.com/hc/en-us/articles/200169436-How-can-I-have-Rocket-Loader-ignore-my-script-s-in-Automatic-Mode- // this must be placed higher. Otherwise it doesn't work. // data-cfasync="false" is for making sure cloudflares' rocketcache doesn't interfeare with this // in this case it only works because it was used at the original script in the text widget function createCookie(name,value,days) { var expires = ""; if (days) { var date = new Date(); date.setTime(date.getTime() + (days*24*60*60*1000)); expires = "; expires=" + date.toUTCString(); } document.cookie = name + "=" + value + expires + "; path=/"; } function readCookie(name) { var nameEQ = name + "="; var ca = document.cookie.split(';'); for(var i=0;i < ca.length;i++) { var c = ca[i]; while (c.charAt(0)==' ') c = c.substring(1,c.length); if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length,c.length); } return null; } function eraseCookie(name) { createCookie(name,"",-1); } // no longer use async because of google // async async function readTextFile(file) { // Helps people browse between pages without the need to keep downloading the same // ads txt page everytime. This way, it allows them to use their browser's cache. var random_number = readCookie("ad_random_number_cookie"); if(random_number == null) { var random_number = Math.floor(Math.random()*100*(new Date().getTime()/10000000000)); createCookie("ad_random_number_cookie",random_number,1) } file += '?t='+random_number; var rawFile = new XMLHttpRequest(); rawFile.onreadystatechange = function () { if(rawFile.readyState === 4) { if(rawFile.status === 200 || rawFile.status == 0) { // var allText = rawFile.responseText; // document.write(allText); document.write(rawFile.responseText); } } } rawFile.open("GET", file, false); rawFile.send(null); } // readTextFile('https://raw.githubusercontent.com/Raynos/file-store/master/temp.txt'); readTextFile("https://www.r-bloggers.com/wp-content/uploads/text-widget_anti-cache.txt"); </script> </div></div> </div> <div id="recent-posts-3" class="sb-widget widget_recent_entries"> <h4 class="widget-title">Recent Posts</h4> <ul> <li> <a href="https://www.r-bloggers.com/2022/05/accessibility-in-r-applications-shiny/" aria-current="page">Accessibility in R applications: {shiny}</a> </li> <li> <a href="https://www.r-bloggers.com/2022/05/automating-and-downloading-google-chrome-images-with-selenium/">Automating and downloading Google Chrome images with Selenium</a> </li> <li> <a href="https://www.r-bloggers.com/2022/05/subsetting-with-multiple-conditions-in-r/">Subsetting with multiple conditions in R</a> </li> <li> <a href="https://www.r-bloggers.com/2022/05/taking-advantage-of-the-constant/">taking advantage of the constant</a> </li> <li> <a href="https://www.r-bloggers.com/2022/05/what-is-nylas-why-did-i-join-and-why-should-you-care/">What is Nylas, why did I join and why should you care?</a> </li> <li> <a href="https://www.r-bloggers.com/2022/05/using-r-and-paws-to-populate-dynamodb-tables-2/">Using R and {paws} to populate DynamoDB tables #2</a> </li> <li> <a href="https://www.r-bloggers.com/2022/05/r-markdown-tips-and-tricks-3-time-savers-trouble-shooters/">R Markdown Tips and Tricks #3: Time-savers & Trouble-shooters</a> </li> <li> <a href="https://www.r-bloggers.com/2022/05/chronicler-is-now-available-on-cran/">chronicler is now available on CRAN</a> </li> <li> <a href="https://www.r-bloggers.com/2022/05/top-5-data-science-take-home-challenges-in-r-programming-language/">Top 5 Data Science Take-Home Challenges in R Programming Language</a> </li> <li> <a href="https://www.r-bloggers.com/2022/05/using-leonardo-svg-palettes-in-r/">Using Leonardo SVG Palettes in R</a> </li> <li> <a href="https://www.r-bloggers.com/2022/05/new-nonlinear-least-squares-solvers-in-r-with-gslnls/">New nonlinear least squares solvers in R with {gslnls}</a> </li> <li> <a href="https://www.r-bloggers.com/2022/05/googlevis-0-7-0-adds-gantt-charts/">googleVis 0.7.0 adds Gantt charts</a> </li> <li> <a href="https://www.r-bloggers.com/2022/05/semantic-html-and-shiny-applications/">Semantic HTML and Shiny Applications</a> </li> <li> <a href="https://www.r-bloggers.com/2022/05/how-to-draw-heatmap-in-r-quick-and-easy-way/">How to draw heatmap in r: Quick and Easy way</a> </li> <li> <a href="https://www.r-bloggers.com/2022/05/part-3-yet-another-pso-article-with-r/">Part 3 – Yet Another PSO Article with R</a> </li> </ul> </div><div id="rss-7" class="sb-widget widget_rss"><h4 class="widget-title"><a class="rsswidget" href="https://feeds.feedburner.com/Rjobs"><img class="rss-widget-icon" style="border:0" width="14" height="14" src="https://www.r-bloggers.com/wp-includes/images/rss.png" alt="RSS" /></a> <a class="rsswidget" href="https://www.r-users.com/">Jobs for R-users</a></h4><ul><li><a class='rsswidget' href='https://www.r-users.com/jobs/junior-data-scientist-quantitative-economist/'>Junior Data Scientist / Quantitative economist</a></li><li><a class='rsswidget' href='https://www.r-users.com/jobs/senior-quantitative-analyst/'>Senior Quantitative Analyst</a></li><li><a class='rsswidget' href='https://www.r-users.com/jobs/r-programmer-4/'>R programmer</a></li><li><a class='rsswidget' href='https://www.r-users.com/jobs/data-scientist-cgiar-excellence-in-agronomy-ref-no-ddg-r4d-ds-1-cg-ea-06-20/'>Data Scientist – CGIAR Excellence in Agronomy (Ref No: DDG-R4D/DS/1/CG/EA/06/20)</a></li><li><a class='rsswidget' href='https://www.r-users.com/jobs/data-analytics-auditor-future-of-audit-lead-london-or-newcastle/'>Data Analytics Auditor, Future of Audit Lead @ London or Newcastle</a></li></ul></div><div id="rss-9" class="sb-widget widget_rss"><h4 class="widget-title"><a class="rsswidget" href="https://feeds.feedburner.com/Python-bloggers"><img class="rss-widget-icon" style="border:0" width="14" height="14" src="https://www.r-bloggers.com/wp-includes/images/rss.png" alt="RSS" /></a> <a class="rsswidget" href="https://python-bloggers.com/">python-bloggers.com (python/data-science news)</a></h4><ul><li><a class='rsswidget' href='https://python-bloggers.com/2022/05/working-with-multiple-arguments-in-a-python-function-using-args-and-kwargs/'>Working with multiple arguments in a Python function using args and kwargs</a></li><li><a class='rsswidget' href='https://python-bloggers.com/2022/05/merge-pdf-files-using-python/'>Merge PDF Files using Python</a></li><li><a class='rsswidget' href='https://python-bloggers.com/2022/05/extract-text-from-image-using-python/'>Extract Text from Image using Python</a></li><li><a class='rsswidget' href='https://python-bloggers.com/2022/05/unzip-files-using-python/'>Unzip Files using Python</a></li><li><a class='rsswidget' href='https://python-bloggers.com/2022/05/top-3-radical-new-features-in-python-3-11-prepare-yourself/'>Top 3 Radical New Features in Python 3.11 – Prepare Yourself</a></li><li><a class='rsswidget' href='https://python-bloggers.com/2022/05/free-checklist-30-days-to-python-programmer/'>Free checklist: 30 days to Python programmer</a></li><li><a class='rsswidget' href='https://python-bloggers.com/2022/05/super-mario-bros-in-the-browser-using-pyscript/'>Super Mario Bros © in the browser using PyScript</a></li></ul></div><div id="text-16" class="sb-widget widget_text"> <div class="textwidget"><strong><a href="https://www.r-bloggers.com/blogs-list/">Full list of contributing R-bloggers</a></strong></div> </div><div id="annual_archive_widget-2" class="sb-widget Annual_Archive_Widget"><h4 class="widget-title">R Posts by Year</h4> <select name="archive-dropdown" onchange='document.location.href=this.options[this.selectedIndex].value;'> <option value="">Select Year</option> <option value='https://www.r-bloggers.com/2022/'> 2022  (666)</option> <option value='https://www.r-bloggers.com/2021/'> 2021  (2343)</option> <option value='https://www.r-bloggers.com/2020/'> 2020  (3278)</option> <option value='https://www.r-bloggers.com/2019/'> 2019  (3086)</option> <option value='https://www.r-bloggers.com/2018/'> 2018  (3479)</option> <option value='https://www.r-bloggers.com/2017/'> 2017  (3718)</option> <option value='https://www.r-bloggers.com/2016/'> 2016  (3477)</option> <option value='https://www.r-bloggers.com/2015/'> 2015  (2858)</option> <option value='https://www.r-bloggers.com/2014/'> 2014  (2894)</option> <option value='https://www.r-bloggers.com/2013/'> 2013  (3141)</option> <option value='https://www.r-bloggers.com/2012/'> 2012  (3382)</option> <option value='https://www.r-bloggers.com/2011/'> 2011  (2847)</option> <option value='https://www.r-bloggers.com/2010/'> 2010  (2046)</option> <option value='https://www.r-bloggers.com/2009/'> 2009  (706)</option> <option value='https://www.r-bloggers.com/2008/'> 2008  (107)</option> <option value='https://www.r-bloggers.com/2007/'> 2007  (70)</option> <option value='https://www.r-bloggers.com/2006/'> 2006  (19)</option> <option value='https://www.r-bloggers.com/2005/'> 2005  (5)</option> </select> </div></aside></div> </div> <div class="copyright-wrap"> <p class="copyright">Copyright © 2022 | <a href="https://www.mhthemes.com/" rel="nofollow">MH Corporate basic by MH Themes</a></p> </div> </div> <!-- TPC! Memory Usage (http://webjawns.com) Memory Usage: 18952808 Memory Peak Usage: 19216424 WP Memory Limit: 820M PHP Memory Limit: 128M Checkpoints: 9 --> <!-- Schema & Structured Data For WP v1.9.97 - --> <script type="application/ld+json" class="saswp-schema-markup-output"> [{"@context":"https://schema.org","@graph":[{"@type":"Organization","@id":"https://www.r-bloggers.com#Organization","name":"R-bloggers","url":"https://www.r-bloggers.com","sameAs":[],"logo":{"@type":"ImageObject","url":"http://www.r-bloggers.com/wp-content/uploads/2021/05/R_blogger_logo1_large.png","width":"1285","height":"369"},"contactPoint":{"@type":"ContactPoint","contactType":"technical support","telephone":"","url":"https://www.r-bloggers.com/contact-us/"}},{"@type":"WebSite","@id":"https://www.r-bloggers.com#website","headline":"R-bloggers","name":"R-bloggers","description":"R news and tutorials contributed by hundreds of R bloggers","url":"https://www.r-bloggers.com","potentialAction":{"@type":"SearchAction","target":"https://www.r-bloggers.com/?s={search_term_string}","query-input":"required name=search_term_string"},"publisher":{"@id":"https://www.r-bloggers.com#Organization"}},{"@context":"https://schema.org","@type":"WebPage","@id":"https://www.r-bloggers.com/2022/05/accessibility-in-r-applications-shiny/#webpage","name":"Accessibility in R applications: {shiny}","url":"https://www.r-bloggers.com/2022/05/accessibility-in-r-applications-shiny/","lastReviewed":"2022-05-19T17:59:00-06:00","dateCreated":"2022-05-19T17:59:00-06:00","inLanguage":"en-US","description":"Let’s take a look at how some of this problem is manifested: If we were to start a shiny app in the usual sort of way this gives the not entirely unattractive UI below (subjective I know). A Lighthouse report, whilst reminding us that gives the following on our “app”: A solution: A solution: A solution: This is where it starts to get a bit more painful… A solution?:","reviewedBy":{"@type":"Organization","name":"R-bloggers","url":"https://www.r-bloggers.com","logo":{"@type":"ImageObject","url":"http://www.r-bloggers.com/wp-content/uploads/2021/05/R_blogger_logo1_large.png","width":"1285","height":"369"}},"primaryImageOfPage":{"@id":"https://www.r-bloggers.com/2022/05/accessibility-in-r-applications-shiny/#primaryimage"},"mainContentOfPage":[[{"@context":"https://schema.org","@type":"SiteNavigationElement","@id":"https://www.r-bloggers.com/#top nav","name":"Home","url":"https://www.r-bloggers.com"},{"@context":"https://schema.org","@type":"SiteNavigationElement","@id":"https://www.r-bloggers.com/#top nav","name":"About","url":"http://www.r-bloggers.com/about/"},{"@context":"https://schema.org","@type":"SiteNavigationElement","@id":"https://www.r-bloggers.com/#top nav","name":"RSS","url":"https://feeds.feedburner.com/RBloggers"},{"@context":"https://schema.org","@type":"SiteNavigationElement","@id":"https://www.r-bloggers.com/#top nav","name":"add your blog!","url":"http://www.r-bloggers.com/add-your-blog/"},{"@context":"https://schema.org","@type":"SiteNavigationElement","@id":"https://www.r-bloggers.com/#top nav","name":"Learn R","url":"https://www.r-bloggers.com/2015/12/how-to-learn-r-2/"},{"@context":"https://schema.org","@type":"SiteNavigationElement","@id":"https://www.r-bloggers.com/#top nav","name":"R jobs","url":"https://www.r-users.com/"},{"@context":"https://schema.org","@type":"SiteNavigationElement","@id":"https://www.r-bloggers.com/#top nav","name":"Submit a new job (it's free)","url":"https://www.r-users.com/submit-job/"},{"@context":"https://schema.org","@type":"SiteNavigationElement","@id":"https://www.r-bloggers.com/#top nav","name":"Browse latest jobs (also free)","url":"https://www.r-users.com/"},{"@context":"https://schema.org","@type":"SiteNavigationElement","@id":"https://www.r-bloggers.com/#top nav","name":"Contact us","url":"http://www.r-bloggers.com/contact-us/"}]],"isPartOf":{"@id":"https://www.r-bloggers.com#website"},"breadcrumb":{"@id":"https://www.r-bloggers.com/2022/05/accessibility-in-r-applications-shiny/#breadcrumb"}},{"@type":"BreadcrumbList","@id":"https://www.r-bloggers.com/2022/05/accessibility-in-r-applications-shiny/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"https://www.r-bloggers.com","name":"R-bloggers"}},{"@type":"ListItem","position":2,"item":{"@id":"https://www.r-bloggers.com/category/r-bloggers/","name":"R bloggers"}},{"@type":"ListItem","position":3,"item":{"@id":"https://www.r-bloggers.com/2022/05/accessibility-in-r-applications-shiny/","name":"Accessibility in R applications: {shiny}"}}]},{"@type":"Article","@id":"https://www.r-bloggers.com/2022/05/accessibility-in-r-applications-shiny/#Article","url":"https://www.r-bloggers.com/2022/05/accessibility-in-r-applications-shiny/","inLanguage":"en-US","mainEntityOfPage":"https://www.r-bloggers.com/2022/05/accessibility-in-r-applications-shiny/#webpage","headline":"Accessibility in R applications: {shiny}","description":"Let’s take a look at how some of this problem is manifested: If we were to start a shiny app in the usual sort of way this gives the not entirely unattractive UI below (subjective I know). A Lighthouse report, whilst reminding us that gives the following on our “app”: A solution: A solution: A solution: This is where it starts to get a bit more painful… A solution?:","articleBody":"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. Do you require help building a Shiny app? Would you like someone to take over the maintenance burden? \tIf so, check out \tour \tShiny and Dash \tservices. \t 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 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 attribute Lists do not only contain <li> elements and script supporting elements 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 the dashboardPage() 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 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 to shiny::*Page() but doesn’t solve the problem for our dashboard. A proposed more general fix would be shinydashboardPlus::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 with ui 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] 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] 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').dataset.value; doSomethingWith(n); } function doSomethingWith(val) { Shiny.setInputValue('sidebarMenu', val); } $(document).ready( function() { $('ul.sidebar-menu li').click(customMenuHandleClick) }); " ) 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 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, as tabindex"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. \t \t For updates and revisions to this article, see the original post","keywords":"","datePublished":"2022-05-19T17:59:00-06:00","dateModified":"2022-05-19T17:59:00-06:00","author":{"@type":"Person","name":"The Jumping Rivers Blog","description":"","url":"https://www.r-bloggers.com/author/the-jumping-rivers-blog/","sameAs":["https://www.jumpingrivers.com/tags/rbloggers/"],"image":{"@type":"ImageObject","url":"https://secure.gravatar.com/avatar/2c87d90261e27a1019b129d763dabdcc?s=96&d=mm&r=g","height":96,"width":96}},"editor":{"@type":"Person","name":"The Jumping Rivers Blog","description":"","url":"https://www.r-bloggers.com/author/the-jumping-rivers-blog/","sameAs":["https://www.jumpingrivers.com/tags/rbloggers/"],"image":{"@type":"ImageObject","url":"https://secure.gravatar.com/avatar/2c87d90261e27a1019b129d763dabdcc?s=96&d=mm&r=g","height":96,"width":96}},"publisher":{"@id":"https://www.r-bloggers.com#Organization"},"image":[{"@type":"ImageObject","url":"https://www.jumpingrivers.com/blog/accessible-shiny-standards-wcag/featured.png","width":600,"height":400,"@id":"https://www.r-bloggers.com/2022/05/accessibility-in-r-applications-shiny/#primaryimage"},{"@type":"ImageObject","url":"https://www.jumpingrivers.com/blog/accessible-shiny-standards-wcag/hello-dashboard.png","width":976,"height":555}],"isPartOf":{"@id":"https://www.r-bloggers.com/2022/05/accessibility-in-r-applications-shiny/#webpage"}}]}] </script> <script> var snp_f = []; var snp_hostname = new RegExp(location.host); var snp_http = new RegExp("^(http|https)://", "i"); var snp_cookie_prefix = ''; var snp_separate_cookies = false; var snp_ajax_url = 'https://www.r-bloggers.com/wp-admin/admin-ajax.php'; var snp_ajax_nonce = 'e01b870d3b'; var snp_ignore_cookies = false; var snp_enable_analytics_events = true; var snp_enable_mobile = false; var snp_use_in_all = false; var snp_excluded_urls = []; </script> <div class="snp-root"> <input type="hidden" id="snp_popup" value="" /> <input type="hidden" id="snp_popup_id" value="" /> <input type="hidden" id="snp_popup_theme" value="" /> <input type="hidden" id="snp_exithref" value="" /> <input type="hidden" id="snp_exittarget" value="" /> <div id="snppopup-welcome" class="snp-pop-109583 snppopup"><input type="hidden" class="snp_open" value="scroll" /><input type="hidden" class="snp_show_on_exit" value="2" /><input type="hidden" class="snp_exit_js_alert_text" value="" /><input type="hidden" class="snp_exit_scroll_down" value="10" /><input type="hidden" class="snp_exit_scroll_up" value="10" /><input type="hidden" class="snp_open_scroll" value="45" /><input type="hidden" class="snp_close_scroll" value="10" /><input type="hidden" class="snp_optin_redirect_url" value="" /><input type="hidden" class="snp_show_cb_button" value="yes" /><input type="hidden" class="snp_popup_id" value="109583" /><input type="hidden" class="snp_popup_theme" value="theme6" /><input type="hidden" class="snp_overlay" value="disabled" /><input type="hidden" class="snp_cookie_conversion" value="30" /><input type="hidden" class="snp_cookie_close" value="180" /><div class="snp-fb snp-theme6"> <div class="snp-subscribe-inner"> <h1 class="snp-header"><i>Never miss an update! </i> <br/> <strong>Subscribe to R-bloggers</strong> to receive <br/>e-mails with the latest R posts.<br/> <small>(You will not see this message again.)</small></h1> <div class="snp-form"> <form action="https://r-bloggers.com/phplist/?p=subscribe&id=1&email=" method="post" class="snp-subscribeform snp_subscribeform" target="_blank"> <fieldset> <div class="snp-field"> <input type="text" name="email" id="snp_email" placeholder="Your E-mail..." class="snp-field snp-field-email" /> </div> <button type="submit" class="snp-submit">Submit</button> </fieldset> </form> </div> <a href="#" class="snp_nothanks snp-close">Click here to close (This popup will not appear again)</a> </div> </div> <style>.snp-pop-109583 .snp-theme6 { max-width: 700px;} .snp-pop-109583 .snp-theme6 h1 {font-size: 17px;} .snp-pop-109583 .snp-theme6 { color: #a0a4a9;} .snp-pop-109583 .snp-theme6 .snp-field ::-webkit-input-placeholder { color: #a0a4a9;} .snp-pop-109583 .snp-theme6 .snp-field :-moz-placeholder { color: #a0a4a9;} .snp-pop-109583 .snp-theme6 .snp-field :-ms-input-placeholder { color: #a0a4a9;} .snp-pop-109583 .snp-theme6 .snp-field input { border: 1px solid #a0a4a9;} .snp-pop-109583 .snp-theme6 .snp-field { color: #000000;} .snp-pop-109583 .snp-theme6 { background: #f2f2f2;} </style><script> jQuery(document).ready(function() { }); </script> </div> <script type="text/javascript"> var CaptchaCallback = function() { jQuery('.g-recaptcha').each(function(index, el) { grecaptcha.render(el, { 'sitekey' : '' }); }); }; </script> </div> <script type="text/javascript">/* <![CDATA[ */!function(e,n){var r={"selectors":{"block":"pre","inline":"code"},"options":{"indent":4,"ampersandCleanup":true,"linehover":true,"rawcodeDbclick":false,"textOverflow":"scroll","linenumbers":false,"theme":"enlighter","language":"r","retainCssClasses":false,"collapse":false,"toolbarOuter":"","toolbarTop":"{BTN_RAW}{BTN_COPY}{BTN_WINDOW}{BTN_WEBSITE}","toolbarBottom":""},"resources":["https:\/\/www.r-bloggers.com\/wp-content\/plugins\/enlighter\/cache\/enlighterjs.min.css?4WJrVky+dDEQ83W","https:\/\/www.r-bloggers.com\/wp-content\/plugins\/enlighter\/resources\/enlighterjs\/enlighterjs.min.js"]},o=document.getElementsByTagName("head")[0],t=n&&(n.error||n.log)||function(){};e.EnlighterJSINIT=function(){!function(e,n){var r=0,l=null;function c(o){l=o,++r==e.length&&(!0,n(l))}e.forEach(function(e){switch(e.match(/\.([a-z]+)(?:[#?].*)?$/)[1]){case"js":var n=document.createElement("script");n.onload=function(){c(null)},n.onerror=c,n.src=e,n.async=!0,o.appendChild(n);break;case"css":var r=document.createElement("link");r.onload=function(){c(null)},r.onerror=c,r.rel="stylesheet",r.type="text/css",r.href=e,r.media="all",o.appendChild(r);break;default:t("Error: invalid file extension",e)}})}(r.resources,function(e){e?t("Error: failed to dynamically load EnlighterJS resources!",e):"undefined"!=typeof EnlighterJS?EnlighterJS.init(r.selectors.block,r.selectors.inline,r.options):t("Error: EnlighterJS resources not loaded yet!")})},(document.querySelector(r.selectors.block)||document.querySelector(r.selectors.inline))&&e.EnlighterJSINIT()}(window,console); /* ]]> */</script><script type='text/javascript' src='https://www.r-bloggers.com/wp-content/plugins/arscode-ninja-popups/js/jquery.ck.min.js?ver=5.5.9' id='jquery-np-cookie-js'></script> <script type='text/javascript' src='https://www.r-bloggers.com/wp-content/plugins/arscode-ninja-popups/js/dialog_trigger.js?ver=5.5.9' id='js-dialog_trigger-js'></script> <script type='text/javascript' src='https://www.r-bloggers.com/wp-content/plugins/arscode-ninja-popups/js/ninjapopups.min.js?ver=5.5.9' id='js-ninjapopups-js'></script> <script type='text/javascript' src='https://www.r-bloggers.com/wp-content/plugins/arscode-ninja-popups/fancybox2/jquery.fancybox.min.js?ver=5.5.9' id='fancybox2-js'></script> <script type='text/javascript' src='https://c0.wp.com/p/jetpack/7.3.3/_inc/build/photon/photon.min.js' id='jetpack-photon-js'></script> <script type='text/javascript' id='flying-pages-js-before'> window.FPConfig= { delay: 0, ignoreKeywords: ["\/wp-admin","\/wp-login.php","\/cart","add-to-cart","logout","#","?",".png",".jpeg",".jpg",".gif",".svg"], maxRPS: 3, hoverDelay: 50 }; </script> <script type='text/javascript' src='https://www.r-bloggers.com/wp-content/plugins/flying-pages/flying-pages.min.js?ver=2.4.2' id='flying-pages-js' defer></script> <script type='text/javascript' src='https://s0.wp.com/wp-content/js/devicepx-jetpack.js?ver=202220' id='devicepx-js'></script> <script type='text/javascript' src='https://c0.wp.com/p/jetpack/7.3.3/_inc/build/lazy-images/js/lazy-images.min.js' id='jetpack-lazy-images-js'></script> <script type='text/javascript' src='https://c0.wp.com/c/5.5.9/wp-includes/js/wp-embed.min.js' id='wp-embed-js'></script> <script type='text/javascript' src='https://stats.wp.com/e-202220.js' async='async' defer='defer'></script> <script type='text/javascript'> _stq = window._stq || []; _stq.push([ 'view', {v:'ext',j:'1:7.3.3',blog:'11524731',post:'326029',tz:'-6',srv:'www.r-bloggers.com'} ]); _stq.push([ 'clickTrackerInit', '11524731', '326029' ]); </script> <script type="text/javascript"> jQuery(document).ready(function ($) { for (let i = 0; i < document.forms.length; ++i) { let form = document.forms[i]; if ($(form).attr("method") != "get") { $(form).append('<input type="hidden" name="bIDiEKaSr" value="[NwX]avgpyM" />'); } if ($(form).attr("method") != "get") { $(form).append('<input type="hidden" name="JtlBfjh" value="JlitU.M3" />'); } if ($(form).attr("method") != "get") { $(form).append('<input type="hidden" name="BNXYvMTtg" value="m6Py@WO3Y0u" />'); } } $(document).on('submit', 'form', function () { if ($(this).attr("method") != "get") { $(this).append('<input type="hidden" name="bIDiEKaSr" value="[NwX]avgpyM" />'); } if ($(this).attr("method") != "get") { $(this).append('<input type="hidden" name="JtlBfjh" value="JlitU.M3" />'); } if ($(this).attr("method") != "get") { $(this).append('<input type="hidden" name="BNXYvMTtg" value="m6Py@WO3Y0u" />'); } return true; }); jQuery.ajaxSetup({ beforeSend: function (e, data) { if (data.type !== 'POST') return; if (typeof data.data === 'object' && data.data !== null) { data.data.append("bIDiEKaSr", "[NwX]avgpyM"); data.data.append("JtlBfjh", "JlitU.M3"); data.data.append("BNXYvMTtg", "m6Py@WO3Y0u"); } else { data.data = data.data + '&bIDiEKaSr=[NwX]avgpyM&JtlBfjh=JlitU.M3&BNXYvMTtg=m6Py@WO3Y0u'; } } }); }); </script> </body> </html> <!-- Dynamic page generated in 1.204 seconds. --> <!-- Cached page generated by WP-Super-Cache on 2022-05-19 17:59:02 --> <!-- Compression = gzip --><script src="/cdn-cgi/scripts/7d0fa10a/cloudflare-static/rocket-loader.min.js" data-cf-settings="14b3f1e5f51c34218afec569-|49" defer></script>