Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Accessible Routing #19290

Merged
merged 16 commits into from
Jan 17, 2020
Merged

Accessible Routing #19290

merged 16 commits into from
Jan 17, 2020

Conversation

madalynrose
Copy link
Contributor

@madalynrose madalynrose commented Nov 5, 2019

Description

MVP for announcing route changes in Gatsby

  • Relies on live announcer region
  • Grabs new location page name from either: first h1, document.title, hardcoded to "new page"

Tested and works with:

Chrome + VoiceOver
NVDA + IE11
Narrator + Edge
Safari + VoiceOver
NVDA + Firefox (bug where it announces multiple times)
Safari + VoiceOver (iOS)
Chrome + TalkBack (Android) announces twice reliably (not sure if this is fixable)

TODO:

  • add localization for announced string
  • create place for configuration (e.g. element id for page name, assertiveness, element id for announcer) to live

@pieh
Copy link
Contributor

pieh commented Nov 5, 2019

  • create place for configuration (e.g. element id for page name, assertiveness, element id for announcer) to live

I wonder if instead of "element id for page name", we could design API bit differently so there is less disconnect when users work on their pages.

Example idea that I'm not sure if technically could work - what if user could use <Announce> component from gatsby to mark what should be announced. Example of page/template:

import React from "react"
import { Announce } from "gatsby"

export default () => (
  <>
    <Announce>
      <h1>Title</h1>
      <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc faucibus eleifend libero in sollicitudin. Nulla facilisi. Pellentesque ultricies ultricies mauris. Etiam commodo tortor bibendum tortor varius mattis. Fusce sollicitudin rhoncus metus, ac vehicula elit hendrerit vel. Cras aliquet, mauris et maximus mollis, nulla est iaculis massa, vitae viverra nulla nunc et dolor. Donec fermentum arcu eu quam viverra malesuada. Suspendisse potenti. Suspendisse fringilla eget mauris facilisis egestas. Duis erat risus, dictum vitae mi nec, scelerisque tristique velit. Ut sed metus est.</p>
    </Announce>
    <p>Maecenas lacinia justo purus, nec semper nunc cursus at. Vivamus pellentesque nec risus vel varius. Fusce hendrerit, dolor non dapibus feugiat, urna justo bibendum leo, ut facilisis velit ipsum et felis. Maecenas fringilla vehicula vestibulum. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Donec volutpat accumsan sem ut molestie. Sed porta tristique est, sit amet posuere nibh mollis id. Donec orci sem, efficitur a enim ut, suscipit vehicula arcu. Vestibulum hendrerit justo eu accumsan semper. Curabitur viverra ipsum sit amet dui auctor, ut posuere orci pharetra. Maecenas sit amet placerat urna, in fringilla dui. Nunc fermentum ornare velit, vitae tempor libero aliquam id.</p>
  </>
)

Which would announce everything within our <Announce> component when navigated to this page?

@madalynrose
Copy link
Contributor Author

I wonder if instead of "element id for page name", we could design API bit differently so there is less disconnect when users work on their pages.

Example idea that I'm not sure if technically could work - what if user could use <Announce> component from gatsby to mark what should be announced. Example of page/template:

Which would announce everything within our <Announce> component when navigated to this page?

This is an interesting idea! I worry that adding a component might make the it more cumbersome for developers and also might make it too easy to include too much content and overwhelm screen reader users. Definitely something to think more on.

</RouteUpdates>
)}
</EnsureResources>
)
}
}

const RouteAnnouncer = () => {
const [announcement, setAnnouncement] = useState(``)
useEffect(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should useEffect run on every update or it can be only for example when location is changed?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think ultimately we would need to use old lifecycle methods anyway (and not hooks) because we declare support for react@^16.4.2 (which is few versions before hooks were introduced):

"peerDependencies": {
"react": "^16.4.2",
"react-dom": "^16.4.2"
},

That's why we don't use any hooks in gatsby runtime right now (which is a bummer).

While this is in work in progress, I don't want to focus on implementation too much yet and first focus on high level ideas/questions:

  • use live-region to do announcements
  • use short content for page change announcements (title, h1, etc) instead of full new page content
  • how users will be able to customize the behaviour (and what should they be able to customize)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what depends to push react dependency to current react version?

@muescha
Copy link
Contributor

muescha commented Nov 6, 2019

Should the name also get it from some aria-label first if they exists on the given elements?

@KyleAMathews
Copy link
Contributor

I like the idea of a built-in component as that'd give fine-grained control to those that want it. @madalynrose we could constrain what people pass in so they can't add too long of messages e.g. < 200 characters or whatever.

@KyleAMathews
Copy link
Contributor

This is exciting!

Only question I had is will this work with react-helmet as that only sets new titles after the initial render of the new page so it could get tricky coordinating with it.

@@ -52,15 +52,19 @@ const RouteHandler = props => (
</BaseContext.Provider>
)

let locationListener
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of this? I can't see how it's used at the moment

Comment on lines 43 to 48
const onRouteUpdate = (location, prevLocation) => {
const onRouteUpdate = (location, prevLocation, announceLocation) => {
if (!maybeRedirect(location.pathname)) {
apiRunner(`onRouteUpdate`, { location, prevLocation })

if (announceLocation) {
announceLocation()
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this logic be moved to the RouteUpdates component?

My initial thought was that it may be easier to maintain if moved there, since its props will always update when the location changes, meaning if we use this API in other places then we wouldn't have to duplicate any logic

Though on second thoughts, it looks like the onRouteUpdate API is only called from the RouteUpdates component, so maybe it wouldn't make a difference either way?

@vtenfys
Copy link
Contributor

vtenfys commented Nov 6, 2019

This is awesome!! Left some comments + I second @KyleAMathews' question about React Helmet

@pieh
Copy link
Contributor

pieh commented Nov 6, 2019

This is an interesting idea! I worry that adding a component might make the it more cumbersome for developers

We would still fallback to document.title or h1 if the component is not there. We also potentially could just support both id and dedicated component (tho supporting two APIs would probably be cumbersome, not to mention that potentially confusing to developers). In any case this is just suggestion, I don't have strong opinion on API design for this

and also might make it too easy to include too much content and overwhelm screen reader users.

This is also true if we would look for specific id in new page to customize what is announced. People could replace <Announce> with <div id="announce-area"> to get same result. Benefit of using dedicated component is that it's easier to implement some validation (like warning users if there is too much content to be announced)

Only question I had is will this work with react-helmet as that only sets new titles after the initial render of the new page so it could get tricky coordinating with it.

I think the additional requestAnimationFrame there is exactly because of react-helmet doing "delayed" update, so it should work with react-helmet as-is.

I was also wondering on how to best provide way for customization (other than pages defining what should be announced) and maybe we could package all of this as a plugin (as we rely on onRouteUpdate anyway, so plugin can do that as well), include that plugin by default and allow users to override plugin options (so very similar to what we do for gatsby-plugin-page-creator):

https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby/src/bootstrap/load-plugins/load.js#L216-L240

Additional benefit is that users could also just add this plugin to older Gatsby version if they don't want to (or can't) update gatsby core version (I imagine that's a stretch here, and we probably won't see this happening)

Lastly - we should release canary version of gatsby with it, as well as publish demo site using those changes for people to try out

Co-Authored-By: David Bailey <4248177+davidbailey00@users.noreply.github.com>
@muescha
Copy link
Contributor

muescha commented Nov 6, 2019

Before it is implemented we should ask impaired people what they most expect from this addition (to avoid they get annoyed by to much and unnecessary text)

maybe they can help us again: https://www.gatsbyjs.org/blog/2019-07-11-user-testing-accessible-client-routing/

I see this PR try to fix this:

In Gatsby–which uses React.js for rendering–we’re currently setting focus to an element wrapping the entire application with a custom implementation of @reach/router. But similar to some of the Reach demos, page content isn’t announced consistently in Safari and Voiceover or NVDA and Firefox–two critical combinations for users of assistive technology. We’re actively working to improve this automatic accessibility support as we explore more component- and research-driven solutions.

@@ -30,6 +30,7 @@ window.___loader = publicLoader
navigationInit()

apiRunnerAsync(`onClientEntry`).then(() => {
console.log(`HELLO`)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stray console log.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why HELLO to you too

@freiksenet freiksenet marked this pull request as ready for review January 17, 2020 08:50
@freiksenet freiksenet requested a review from a team as a code owner January 17, 2020 08:50
@@ -94,6 +94,69 @@ apiRunnerAsync(`onClientEntry`).then(() => {
}
}

// class RouteAnnouncer extends React.Component {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can be removed now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch!

Copy link
Contributor

@freiksenet freiksenet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is ready to get shipped!

It's shipping time

></div>
)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this component doesnt render any dynamic data, I wonder if we could add a

shouldComponentUpdate() { return false; }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is still getting used to using refs like this

does this.announcementRef.current.innerText not count as dynamic data being rendered?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nope. On initial render, the ref will be passed to React, who will attach it to the node. It happens asynchronous and does not affect component lifecycles.

Changing the ref will not cause any re-renders ever!

Copy link
Contributor Author

@madalynrose madalynrose Jan 17, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay I'm trying to leverage my new-ness to React as something useful and ask my potentially trivial questions out in the open 🙈

Our changes happen to the announcement ref happen within the componentDidUpdate lifecycle method. Wouldn't returning false inside shouldComponentUpdate prevent it from being invoked?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great questions! And you know what, I'm wrong. 😆 Since you are doing it in DidUpdate, that occurs after a render. So stopping update stops that cycle. Let's just ship this, I think there are some changes that would better align with React, but this works. So let's ship and we can see if there are better iterations later :)

constructor(props) {
super(props)
this.announcementRef = React.createRef()
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ES6 tip (feel free to do this if you want). But you can ditch the constructor here so we're just instantiating a class variable. Do it like this instead:

class Router {
  announcementRef = React.createRef()
}

It'll achieve the same result! (if this doesn't work ignore, it does require a specific babel setup to make it work and maybe we don't have that)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'm going to leave this for a later iteration. I'm ready to get this thing OUT!

pageName = pageHeadings[0].textContent
}
const newAnnouncement = `Navigated to ${pageName}`
const oldAnnouncement = this.announcementRef.current.innerText
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, one thing I just thought of. We should add a bailout check if the ref isn't attached yet. It feels unlikely to occur, but technically ref's type is {current: HTMLElement | null}. It's possible this could come up later on with suspense. All you need to do is at the top of this rAF, put a if (!this.announcementRef.current) return

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@blainekasten Suspense won’t change anything here.

It’s impossible for the ref to be null during componentDidUpdate, though note that because of the rAF call it is possible (though probably unlikely) for the component to unmount before this code runs, so a check is appropriate.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@blainekasten I think this check is still missing in the code. Just talked to someone on Discord getting Cannot read property 'innerText' of null on this line. Don't know if that's the cause of his problem, or a side-effect.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm getting this error while trying to debug problems with Ms Edge (14). Happens when I try to navigate to a new page.

Copy link
Contributor

@blainekasten blainekasten left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is awesome! I'm in approval!!

@madalynrose madalynrose merged commit 701222f into master Jan 17, 2020
@madalynrose madalynrose deleted the a11y-routing-mvp branch January 17, 2020 18:36
@wKovacs64
Copy link
Contributor

Ironically, this is causing Gatsby to no longer pass a11y tests (via cypress-axe) in one of my projects:

image

Should it be wrapped in something or go elsewhere, perhaps?

@madalynrose
Copy link
Contributor Author

madalynrose commented Jan 18, 2020

Thanks for letting us know, @wKovacs64! Could you create an issue and add more about your setup (or point to your repo)? I'll dive into this first thing on Tuesday.

return (
<div
id="gatsby-announcer"
style={{
Copy link

@JustFly1984 JustFly1984 Jan 18, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please extract object to const outside of class scope, or React.useMemo hook

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add eslint-plugin-react-perf, to escape this kind of performance implications.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a very minor perf issue. Creating one object on re-renders (which only happen on route change). It's probably easier to just implement a shouldComponentUpdate: () => false

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Easier - does not mean best way. Counting that react moving to functions, instead of components, and original advise do not use shouldComponentUpdate, this style object already a symptom that there could be much more performance issues in gatsby.js project itself. A lot of very minor issues adds up pretty darn fast

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Former React team manager here – I can guarantee this will never be a performance problem and there is nothing wrong with writing the style object like this. I’d recommend you stop worrying about microoptimizing code.

if (pageHeadings && pageHeadings.length) {
pageName = pageHeadings[0].textContent
}
const newAnnouncement = `Navigated to ${pageName}`

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how about internationalization support?

@wKovacs64
Copy link
Contributor

Thanks for letting us know, @wKovacs64! Could you create an issue and add more about your setup (or point to your repo)? I'll dive into this first thing on Tuesday.

Opened #20697, thanks.

@luukdv
Copy link

luukdv commented Jan 19, 2020

Just noticed the #gatsby-announcer element causes a vertical scrollbar on 100vh pages, even when empty. Looks like a default styling of height: 1px; causes the issue, which is present when the element doesn't have content.

Any way to turn this off for apps that don't use routing? Or not render/apply those styles when the element is empty?

@madalynrose
Copy link
Contributor Author

@luukdv Thanks for letting us know! Could you open an issue for this and add more about the browser/platform you're seeing this on, possibly with a link to a page where you can reproduce the issue? I'll be focusing on bug fixes first thing on Tuesday.

@madalynrose madalynrose mentioned this pull request Aug 11, 2020
3 tasks
kodiakhq bot pushed a commit to vercel/next.js that referenced this pull request Mar 15, 2021
# Route Announcements

## Summary
This PR improves the accessibility of NextJS's client-side navigation by announcing route changes to screen readers.

## Context
When a user who is sighted clicks on a link, they can see the content change. It's an affirmation that what the user intended to do by clicking a link actually worked! Users navigating the page via a screen-reader will not get this feedback on NextJS sites (This is an issue on many SPA-like architectures).

https://user-images.githubusercontent.com/4213649/103017382-63b02b00-44f8-11eb-9940-fb530d2d3018.mov

## Solution
Whenever there is a route change, the new `<RouteAnnouncer />` will look for a name to give the new page and then announce it! The name is found by first looking for an `h1`, falling back to `document.title`, and lastly to `pathname`. `<RouteAnnouncer />` is a visually hidden component placed within the `<AppContainer />`.

## Demo
https://user-images.githubusercontent.com/4213649/103017401-6ad73900-44f8-11eb-8050-b3e9a7e0c3f2.mov

## Inspiration
First and foremost, this PR was inspired by @marcysutton's studies and writing, [What we learned from user testing of accessible client-side routing techniques with Fable Tech Labs
](https://www.gatsbyjs.com/blog/2019-07-11-user-testing-accessible-client-routing/) as well as @madalynrose's [Accessible Routing](gatsbyjs/gatsby#19290) PR for Gatsby.

There were also learnings gleaned from the conversations within #7681.

### Related Issues & PRs
- Resolves #7681
- Relates to #19963
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.