├── .gitignore ├── LICENSE ├── README.md ├── cypress.json ├── cypress ├── fixtures │ └── example.json ├── integration │ └── index.js ├── plugins │ └── index.js └── support │ ├── commands.js │ └── index.js ├── examples ├── client-side-routing │ ├── components │ │ ├── header.js │ │ └── route-target-heading.js │ ├── gatsby-browser.js │ └── pages │ │ └── page.jsx ├── dropdown │ └── dropdown.js ├── integration-testing │ ├── nav.spec.js │ └── routing.js ├── live-region │ └── live-region.js ├── progressive-enhancement │ └── tab-list.js ├── reduced-motion │ ├── animation.js │ └── card-flip.scss └── unit-testing │ └── dropdown.test.js ├── gatsby-browser.js ├── gatsby-config.js ├── gatsby-node.js ├── jest-preprocess.js ├── jest.config.js ├── jest.setup.js ├── loadershim.js ├── outline.yml ├── package-lock.json ├── package.json ├── src ├── __mocks__ │ └── file-mock.js ├── components │ ├── bad │ │ ├── animation.js │ │ ├── async-form.js │ │ ├── card-flip.js │ │ ├── card-flip.scss │ │ ├── dropdown.js │ │ └── dropdown.scss │ ├── better │ │ ├── __tests__ │ │ │ └── dropdown.test.js │ │ ├── animation.js │ │ ├── card-flip.js │ │ ├── card-flip.scss │ │ ├── downshift-autocomplete.js │ │ ├── downshift-utils.js │ │ ├── dropdown.js │ │ ├── dropdown.scss │ │ ├── live-region.js │ │ ├── reduced-motion.js │ │ ├── route-target-heading.js │ │ └── tab-list.js │ ├── site-chrome │ │ ├── code-figure.jsx │ │ ├── header.js │ │ ├── image.js │ │ ├── layout.js │ │ ├── layout.scss │ │ ├── navigation.js │ │ ├── navigation.module.scss │ │ └── seo.js │ └── taco-component.js ├── css │ └── reset.css ├── images │ ├── bagley.jpg │ ├── blueicon.jpg │ └── rainier-headshot.jpg ├── pages │ ├── 404.js │ ├── animation.jsx │ ├── announcer.jsx │ ├── dropdown.jsx │ ├── enhanced-tablist.jsx │ ├── index.jsx │ ├── layout.jsx │ └── semantics.jsx ├── slides │ └── index.mdx ├── templates │ ├── image-slide.jsx │ └── slide.jsx └── theme.js └── static ├── a11y-octopus.png ├── aea-grid-inspect.png ├── angular-logo.png ├── announcement.webp ├── axe-logo.png ├── cheat-sheet_page_1.png ├── cheat-sheet_page_2.png ├── chrome-a11y-inspector.png ├── chrome-accessibility-tree.png ├── chrome-devtools-component-tree.png ├── ci-bandits.jpg ├── elmo-shrug.gif ├── finally.webp ├── focus-diagram.png ├── gatsby-icon.png ├── graphiql.png ├── how-gatsby-works.png ├── keyboard.gif ├── magnification-a11y.png ├── mail.gif ├── marcysutton-sketch.png ├── mythbusters.png ├── paul-adam-aria.png ├── react-logo.png ├── safari-settings.png └── vscode-tour.png /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # dotenv environment variables file 55 | .env 56 | 57 | # gatsby files 58 | .cache/ 59 | public 60 | 61 | # Mac files 62 | .DS_Store 63 | 64 | # Yarn 65 | yarn-error.log 66 | .pnp/ 67 | .pnp.js 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 gatsbyjs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Building Accessible Sites with Gatsby 2 | 3 | Learn the necessary techniques and tools for building inclusive web applications with Gatsby and React.js from Gatsby's Head of Learning Marcy Sutton. 4 | 5 | Presented at: 6 | - [Gatsby Days LA](https://www.gatsbyjs.com/gdla-a11y-workshop/) 7 | - [Smashing Conf NYC](https://smashingconf.com/ny-2019/) 8 | 9 | Some key takeaways: 10 | 11 | - Understand how to incorporate accessibility into your web development workflow. 12 | - Debug your sites and applications for accessibility using the latest tools. 13 | - Apply accessibility to React web applications with Gatsby, while learning how accessibility applies to other stacks. 14 | - Learn the benefits of manual and automated testing to grow web accessibility superpowers! 15 | - Integrate focus management into your web applications, gracefully handling keyboard and screen reader interactions. 16 | - Practice announcing view changes with your code and keeping screen reader users up to date. 17 | - Achieve wins with semantic markup, unobtrusive animation, and progressive enhancement. 18 | 19 | App URL: https://marcysutton.github.io/gatsby-a11y-workshop 20 | 21 | Workshop slides: https://marcysutton.github.io/gatsby-a11y-workshop/slides/ 22 | 23 | --- 24 | 25 | This project was started with [gatsby-starter-mdx-basic](https://github.com/christopherbiscardi/gatsby-starter-mdx-basic) and [@mdx-deck/theme](https://github.com/jxnblk/mdx-deck/tree/master/packages/gatsby-theme). 26 | 27 | _Note: This repo requires [Node 12 and npm](https://nodejs.org) to be installed._ 28 | 29 | 1. Install Gatsby CLI: 30 | 31 | ```sh 32 | npm install -g gatsby-cli 33 | ``` 34 | 35 | 1. Create a new Gatsby site and slide deck using this starter 36 | 37 | ```sh 38 | gatsby new gatsby-a11y-workshop https://github.com/marcysutton/gatsby-a11y-workshop 39 | ``` 40 | 41 | 3. Go into the directory and start the development server 42 | 43 | ```sh 44 | cd gatsby-a11y-workshop 45 | gatsby develop 46 | ``` 47 | 48 | View in a browser: http://localhost:8000 49 | 50 | 4. Edit files: 51 | 52 | - Site pages: [`src/pages/*`](https://github.com/marcysutton/gatsby-a11y-workshop/blob/master/src/pages) 53 | - Site components: [`src/components/*`](https://github.com/marcysutton/gatsby-a11y-workshop/blob/master/src/components) 54 | - Templates: [`src/templates/*`](https://github.com/marcysutton/gatsby-a11y-workshop/blob/master/src/templates) 55 | - Slide content: [`src/slides/index.mdx`](https://github.com/marcysutton/gatsby-a11y-workshop/blob/master/src/slides/index.mdx) 56 | 57 | 5. To look at the answers from the exercises, check out the [`/examples`](https://github.com/marcysutton/gatsby-a11y-workshop/blob/master/examples) directory in the `master` branch 58 | 59 | You can also check out the `solutions` branch to see everything in action: https://github.com/marcysutton/gatsby-a11y-workshop/tree/solutions 60 | 61 | ### Prerequisites 62 | 1. Have a text editor installed, i.e. VSCode 63 | 2. Have Node.js 12+ and npm installed 64 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /cypress/integration/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | module.exports = (on, config) => { 15 | // `on` is used to hook into various events Cypress emits 16 | // `config` is the resolved Cypress config 17 | } 18 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | 11 | import "cypress-axe" 12 | import "@testing-library/cypress/add-commands" -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /examples/client-side-routing/components/header.js: -------------------------------------------------------------------------------- 1 | import { Link } from 'gatsby' 2 | import PropTypes from 'prop-types' 3 | import React from 'react' 4 | 5 | const Header = ({ siteTitle }) => ( 6 |
7 | 10 |

11 | 14 | {siteTitle} 15 | 16 |

17 |
18 | ) 19 | 20 | Header.propTypes = { 21 | siteTitle: PropTypes.string, 22 | } 23 | 24 | Header.defaultProps = { 25 | siteTitle: '', 26 | } 27 | 28 | export default Header 29 | -------------------------------------------------------------------------------- /examples/client-side-routing/components/route-target-heading.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { css } from "@emotion/core" 3 | 4 | const styles = css` 5 | .routeSkipHeading { 6 | position: relative; 7 | } 8 | .routeSkipLink { 9 | display: inline-block; 10 | margin-left: -0.75em; 11 | opacity: 0; 12 | position: absolute; 13 | text-decoration: none; 14 | } 15 | .routeSkipLink:before { 16 | content: '⇽'; 17 | display: block; 18 | } 19 | .routeSkipLink:focus, 20 | .routeSkipLink:hover { 21 | opacity: 1; 22 | } 23 | ` 24 | const RouteHeading = ({level = 1, targetID, children}) => { 25 | const Heading = `h${level}`; 26 | return ( 27 | 28 | 32 | 33 | {children} 34 | 35 | ) 36 | } 37 | export default RouteHeading -------------------------------------------------------------------------------- /examples/client-side-routing/gatsby-browser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implement Gatsby's Browser APIs in this file. 3 | * 4 | * See: https://www.gatsbyjs.org/docs/browser-apis/ 5 | */ 6 | let liveRegion = null 7 | 8 | exports.onRouteUpdate = ({ location, prevLocation }) => { 9 | if (prevLocation !== null) { 10 | const skipLink = document.querySelector('.routeSkipLink') 11 | if (skipLink) { 12 | skipLink.focus() 13 | } 14 | // update live region with page change 15 | if (location.pathname === '/') { 16 | liveRegion.textContent = 'home page' 17 | } else { 18 | liveRegion.textContent = location.pathname.split('/')[1] + ' page' 19 | } 20 | } 21 | } 22 | 23 | exports.onClientEntry = () => { 24 | liveRegion = document.createElement('div') 25 | liveRegion.setAttribute('role', 'status') 26 | liveRegion.classList.add('visually-hidden') 27 | liveRegion.id = 'routing-region' 28 | 29 | document.body.appendChild(liveRegion) 30 | } -------------------------------------------------------------------------------- /examples/client-side-routing/pages/page.jsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | import Layout from '../components/site-chrome/layout' 4 | import SEO from '../components/site-chrome/seo' 5 | 6 | import RouteTargetHeading from "./route-target-heading.js" 7 | 8 | const HeadingDemoPage = () => { 9 | return ( 10 | 11 | 12 |
13 | 17 | Heading Demo 18 | 19 |
20 |
21 | ) 22 | } 23 | 24 | export default HeadingDemoPage 25 | 26 | 27 | -------------------------------------------------------------------------------- /examples/dropdown/dropdown.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from "react" 2 | 3 | import "./dropdown.scss" 4 | 5 | const Dropdown = ({ activatorText = 'Dropdown', items = [] }) => { 6 | const [isOpen, setIsOpen] = useState(false) 7 | const activatorRef = useRef(null) 8 | const dropdownListRef = useRef(null) 9 | 10 | const wrapKeyHandler = (event) => { 11 | if (event.key === 'Escape' && isOpen) { 12 | // escape key 13 | setIsOpen(false) 14 | activatorRef.current.focus() 15 | } 16 | } 17 | const clickHandler = () => { 18 | setIsOpen(!isOpen) 19 | } 20 | const clickOutsideHandler = (event) => { 21 | if (dropdownListRef.current.contains(event.target) || activatorRef.current.contains(event.target)) { 22 | return 23 | } 24 | setIsOpen() 25 | } 26 | useEffect(() => { 27 | if (isOpen) { 28 | document.addEventListener('mouseup', clickOutsideHandler) 29 | 30 | dropdownListRef.current.querySelector('a').focus() 31 | } else { 32 | document.removeEventListener('mouseup', clickOutsideHandler) 33 | } 34 | 35 | return () => { 36 | document.removeEventListener('mouseup', clickOutsideHandler) 37 | } 38 | }, [isOpen]) 39 | return ( 40 |
44 | 54 | 68 |
69 | ) 70 | } 71 | export default Dropdown 72 | -------------------------------------------------------------------------------- /examples/integration-testing/nav.spec.js: -------------------------------------------------------------------------------- 1 | context("Nav menu", () => { 2 | beforeEach(() => { 3 | cy.visit(`https://marcysutton.github.io/gatsby-a11y-workshop`) 4 | cy.injectAxe() 5 | cy.wait(100) 6 | }) 7 | it("has no accessibility violations on load", () => { 8 | cy.checkA11y() 9 | }) 10 | it("has a focusable, labeled button", () => { 11 | cy.get("[aria-label='Open menu']").focus() 12 | cy.focused().should("have.attr", "aria-label") 13 | }) 14 | }) -------------------------------------------------------------------------------- /examples/integration-testing/routing.js: -------------------------------------------------------------------------------- 1 | /// 2 | describe("Accessibility checks", () => { 3 | it("Has no detectable a11y violations on load", () => { 4 | cy.visit("http://localhost:8000") 5 | cy.injectAxe() 6 | cy.wait(500) 7 | cy.checkA11y() 8 | }) 9 | it("Handles focus on route change via click", () => { 10 | cy.visit("http://localhost:8000") 11 | cy.focused() 12 | .should("not.have.class", "routeSkipLink") 13 | 14 | cy.get('#page-navigation').find('a').eq(0).click() 15 | 16 | cy.focused() 17 | .should("have.class", "routeSkipLink") 18 | }) 19 | }) -------------------------------------------------------------------------------- /examples/live-region/live-region.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useRef} from "react" 2 | 3 | const LiveRegion = () => { 4 | const [message, setMessage] = useState('') 5 | const inputRef = useRef(null) 6 | 7 | const start = () => { 8 | let typedMessage = inputRef.current.value 9 | if (typedMessage.trim().length) { 10 | setMessage(typedMessage) 11 | } 12 | } 13 | const submitHandler = (event) => { 14 | event.preventDefault() 15 | start() 16 | } 17 | return ( 18 |
19 |
20 |

{ message ? message : 'nothing yet.' }

21 |
22 | 26 |
27 | 28 |
29 |
30 | ) 31 | } 32 | 33 | export default LiveRegion -------------------------------------------------------------------------------- /examples/progressive-enhancement/tab-list.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | 3 | const TabList = ({ items = [] }) => { 4 | const [isClient, setClient] = useState(false) 5 | /* 6 | * Think of this as componentDidMount 7 | * on the client, it will run once and update state 8 | */ 9 | useEffect(() => { 10 | setClient(true) 11 | }, []) 12 | return ( 13 | 22 | ) 23 | } 24 | 25 | export default TabList -------------------------------------------------------------------------------- /examples/reduced-motion/animation.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState, useEffect } from "react" 2 | import { css } from "@emotion/core" 3 | import styled from "@emotion/styled" 4 | 5 | const noAnimate = css` 6 | .pulse, circle { 7 | animation: none !important; 8 | } 9 | .pulse circle { 10 | opacity: 0.75; 11 | transform: scale(0.25); 12 | } 13 | .pulse circle:nth-child(2) { 14 | opacity: 0.25; 15 | transform: scale(0.75); 16 | } 17 | .pulse circle:nth-child(3) { 18 | opacity: 0.5; 19 | transform: scale(0.5); 20 | } 21 | ` 22 | const Demo = styled.div` 23 | .animationToggle { 24 | background-color: black; 25 | border: none; 26 | border-radius: 0; 27 | color: white; 28 | font-size: 1rem; 29 | padding: 0.5em 1em; 30 | } 31 | .animationTarget { 32 | height: 400px; 33 | margin: 1em auto; 34 | overflow: hidden; 35 | position: relative; 36 | width: 400px; 37 | } 38 | .pulse { 39 | z-index: 1; 40 | position: absolute; 41 | top: 50%; 42 | left: 50%; 43 | transform: translate(-50%, -50%); 44 | max-width: 30rem; 45 | 46 | circle { 47 | fill: #ff5154; 48 | transform: scale(0); 49 | opacity: 0.4; 50 | transform-origin: 50% 50%; 51 | animation: pulse 2.5s cubic-bezier(.5,.5,0,1) infinite; 52 | 53 | &:nth-child(2) { 54 | fill: #7fc6a4; 55 | animation: pulse 2.5s 0.75s cubic-bezier(.5,.5,0,1) infinite; 56 | } 57 | 58 | &:nth-child(3) { 59 | fill: #e5f77d; 60 | animation: pulse 2.5s 1.5s cubic-bezier(.5,.5,0,1) infinite; 61 | } 62 | 63 | } 64 | 65 | } 66 | 67 | @keyframes pulse { 68 | 25% { 69 | opacity: 0.4; 70 | } 71 | 72 | 100% { 73 | transform: scale(1); 74 | opacity: 0; 75 | } 76 | } 77 | 78 | 79 | .no-animate { 80 | ${noAnimate} 81 | } 82 | 83 | /* if reduced-motion is selected on OSX/iOS */ 84 | @media (prefers-reduced-motion) { 85 | /* hide toggle button */ 86 | .animationToggle { 87 | display: none; 88 | } 89 | /* make sure animations actually stop */ 90 | ${noAnimate} 91 | } 92 | ` 93 | 94 | const AccessibleAnimationDemo = () => { 95 | let [animating, setAnimating] = useState(false) 96 | let [toggleText, setToggleText] = useState('on') 97 | 98 | const animationTarget = useRef() 99 | const animationToggle = useRef() 100 | 101 | useEffect(() => { 102 | if (localStorage.getItem('animating') === 'false') { 103 | disableAnimation() 104 | } 105 | }, [animating]) 106 | 107 | function toggleBtnHandler(event) { 108 | if (animating) { 109 | disableAnimation() 110 | } else { 111 | enableAnimation() 112 | } 113 | } 114 | function disableAnimation() { 115 | setToggleText('on') 116 | setAnimating(false) 117 | localStorage.setItem('animating', 'false') 118 | } 119 | function enableAnimation() { 120 | setToggleText('off') 121 | setAnimating(true) 122 | localStorage.setItem('animating', 'true') 123 | } 124 | return ( 125 | 126 |
131 | 132 | Animating circles 133 | 134 | 135 | 136 | 137 |
138 |
139 | 146 |
147 |
148 | ) 149 | } 150 | 151 | export default AccessibleAnimationDemo 152 | -------------------------------------------------------------------------------- /examples/reduced-motion/card-flip.scss: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | .team-member { 5 | display: inline-block; 6 | position: relative; 7 | } 8 | .team-content { 9 | padding: 0; 10 | position: relative; 11 | z-index: 1; 12 | margin-top: 0; 13 | margin-bottom: 0px; 14 | width: 320px; 15 | text-align: center; 16 | } 17 | .team-content button { 18 | background-color: transparent; 19 | border: none; 20 | color: currentColor; 21 | -webkit-appearance: none; 22 | } 23 | .team-content .toggle-button * { 24 | text-decoration: underline; 25 | } 26 | .team-image { 27 | display: block; 28 | width: 190px; 29 | height: 190px; 30 | overflow: hidden; 31 | margin: auto; 32 | margin-top: 60px; 33 | margin-bottom: 20px; 34 | border-radius: 90px; 35 | box-shadow: 0 6px 20px rgba(0, 0, 0, 0.07); 36 | text-align: center; 37 | } 38 | .team-content .team-name { 39 | display: block; 40 | font-size: 18px; 41 | font-weight: 700; 42 | line-height: 1; 43 | margin: 0; 44 | margin-bottom: 10px; 45 | } 46 | .team-content .team-subtitle { 47 | margin-bottom: 0px; 48 | display: block; 49 | font-size: 14px; 50 | font-style: italic; 51 | line-height: 1; 52 | color: inherit; 53 | } 54 | .team-member .team-image img { 55 | margin: 0 auto; 56 | border: none; 57 | height: auto; 58 | width: 100%; 59 | } 60 | .team-content-overlay { 61 | background-color: #1c6300; 62 | color: #fff; 63 | top: 0; 64 | padding: 40px 35px; 65 | position: absolute; 66 | width: 190px; 67 | overflow: hidden; 68 | min-height: 190px; 69 | max-height: 190px; 70 | height: auto; 71 | left: 60px; 72 | opacity: 0.5; 73 | visibility: hidden; 74 | -webkit-transition: 0.25s ease-in; 75 | -o-transition: 0.25s ease-in; 76 | transition: 0.25s ease-in; 77 | z-index: 100; 78 | border-radius: 90px; 79 | } 80 | .active .team-content-overlay { 81 | opacity: 1; 82 | visibility: visible; 83 | margin-left: calc(95px - 50%); 84 | margin-top: 30px; 85 | max-height: 400px; 86 | width: 100%; 87 | height: auto; 88 | border-radius: 10px; 89 | } 90 | .team-content-overlay .gradient-overlay { 91 | position: absolute; 92 | width: 100%; 93 | height: 100%; 94 | z-index: 0; 95 | top: 0; 96 | left: 0; 97 | } 98 | .team-content .team-content-overlay .team-name { 99 | font-size: 18px; 100 | font-weight: 700; 101 | position: relative; 102 | } 103 | .team-content .team-content-overlay .team-subtitle { 104 | position: relative; 105 | } 106 | .team-close-button { 107 | border: none; 108 | font-weight: bold; 109 | position: absolute; 110 | top: 10px; 111 | right: 10px; 112 | 113 | &:focus { 114 | outline: 3px solid lightblue; 115 | } 116 | } 117 | .team-content-overlay p { 118 | color: #fff; 119 | opacity: 0; 120 | font-size: 8px; 121 | font-weight: 400; 122 | line-height: 22px; 123 | position: relative; 124 | font-size: 12px; 125 | // -webkit-transition: 0.25s ease-in; 126 | // -o-transition: 0.25s ease-in; 127 | // transition: 0.25s ease-in; 128 | } 129 | .active .team-content-overlay p { 130 | opacity: 1; 131 | font-size: 12px; 132 | } 133 | .team-socials { 134 | position: relative; 135 | margin: auto; 136 | margin-top: 45px; 137 | width: 100%; 138 | z-index: 101; 139 | text-align: center; 140 | line-height: 12px; 141 | } 142 | .team-socials a { 143 | color: #225165; 144 | outline: 0; 145 | text-decoration: none; 146 | } 147 | .team-socials svg { 148 | margin: 0 10px; 149 | width: 25px; 150 | } 151 | @media (prefers-reduced-motion) { 152 | .team-content-overlay { 153 | transition: opacity 0.25s ease-out; 154 | } 155 | .team-content-overlay p { 156 | transition: none; 157 | } 158 | } -------------------------------------------------------------------------------- /examples/unit-testing/dropdown.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, fireEvent } from '@testing-library/react' 3 | 4 | import Dropdown from '../dropdown' 5 | 6 | describe(`Dropdown`, () => { 7 | it(`renders activatorText`, () => { 8 | const activatorText = `Hamburgers` 9 | const { getByText } = render() 10 | 11 | const text = getByText(activatorText) 12 | 13 | expect(text).toBeInTheDocument() 14 | }) 15 | it(`renders a focusable button that activates the dropdown`, () => { 16 | const activatorText = `Dogs` 17 | const items = [{ 18 | text: 'item 1', 19 | url: '#' 20 | }, { 21 | text: 'item 2', 22 | url: '#' 23 | }] 24 | const dropdown = render() 25 | 26 | const activator = dropdown.getByTestId('dropdown-activator') 27 | activator.focus() 28 | 29 | expect(activator).toHaveFocus() 30 | 31 | fireEvent.click(activator) 32 | 33 | const dropdownList = dropdown.getByTestId('dropdown-itemList') 34 | 35 | const firstAnchor = dropdownList.querySelector('a') 36 | expect(firstAnchor).toHaveFocus() 37 | 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /gatsby-browser.js: -------------------------------------------------------------------------------- 1 | exports.onRouteUpdate = ({ location, prevLocation }) => { 2 | 3 | } -------------------------------------------------------------------------------- /gatsby-config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | siteMetadata: { 5 | title: `Building Accessible Sites with Gatsby`, 6 | description: 7 | `Learn the necessary techniques and tools for building inclusive web applications with Gatsby.`, 8 | author: `@marcysutton`, 9 | }, 10 | pathPrefix: "/gatsby-a11y-workshop", 11 | plugins: [ 12 | { 13 | resolve: `gatsby-theme-mdx-deck`, 14 | options: { 15 | mdx: true, 16 | // source directory for decks 17 | contentPath: `src/slides`, 18 | basePath: '/slides', 19 | }, 20 | }, 21 | `gatsby-plugin-sass`, 22 | `gatsby-plugin-react-helmet`, 23 | { 24 | resolve: `gatsby-source-filesystem`, 25 | options: { 26 | name: `images`, 27 | path: `${__dirname}/src/images`, 28 | }, 29 | }, 30 | { 31 | resolve: `gatsby-source-filesystem`, 32 | options: { 33 | name: `pages`, 34 | path: `${__dirname}/src/pages` 35 | } 36 | }, 37 | `gatsby-transformer-sharp`, 38 | `gatsby-plugin-sharp`, 39 | { 40 | resolve: `gatsby-plugin-manifest`, 41 | options: { 42 | name: `gatsby-a11y-workshop`, 43 | short_name: `gatsbya11y`, 44 | start_url: `/`, 45 | background_color: `#663399`, 46 | theme_color: `#663399`, 47 | display: `minimal-ui`, 48 | icon: `src/images/blueicon.jpg`, // This path is relative to the root of the site. 49 | }, 50 | }, 51 | // this (optional) plugin enables Progressive Web App + Offline functionality 52 | // To learn more, visit: https://gatsby.app/offline 53 | // `gatsby-plugin-offline`, 54 | ], 55 | } 56 | -------------------------------------------------------------------------------- /gatsby-node.js: -------------------------------------------------------------------------------- 1 | exports.onCreateBabelConfig = function onCreateBabelConfig({ actions }) { 2 | actions.setBabelPlugin({ 3 | name: `@babel/plugin-proposal-export-default-from` 4 | }) 5 | } 6 | -------------------------------------------------------------------------------- /jest-preprocess.js: -------------------------------------------------------------------------------- 1 | const babelOptions = { 2 | presets: ["babel-preset-gatsby"], 3 | } 4 | 5 | module.exports = require("babel-jest").createTransformer(babelOptions) -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | "^.+\\.jsx?$": `/jest-preprocess.js`, 4 | ".+\\.(css|styl|less|sass|scss)$": "jest-transform-css" 5 | }, 6 | moduleNameMapper: { 7 | ".+\\.(css|styl|less|sass|scss)$": `identity-obj-proxy`, 8 | ".+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": `/__mocks__/file-mock.js`, 9 | }, 10 | testPathIgnorePatterns: [`node_modules`, `.cache`, `examples`], 11 | transformIgnorePatterns: [`node_modules/(?!(gatsby)/)`], 12 | globals: { 13 | __PATH_PREFIX__: ``, 14 | }, 15 | testURL: `http://localhost`, 16 | setupFiles: [`/loadershim.js`], 17 | setupFilesAfterEnv: ["/jest.setup.js"], 18 | 19 | } -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | require(`@testing-library/jest-dom/extend-expect`) 2 | require(`@testing-library/react/cleanup-after-each`) -------------------------------------------------------------------------------- /loadershim.js: -------------------------------------------------------------------------------- 1 | global.___loader = { 2 | enqueue: jest.fn(), 3 | } -------------------------------------------------------------------------------- /outline.yml: -------------------------------------------------------------------------------- 1 | 2 | Outline: 3 | - Introduction 4 | - Who is Marcy Sutton? 5 | - Overview of what we’ll cover 6 | - Examples of inaccessible web applications: show the pain 7 | - Examples of better web applications 8 | - Built HTML pages in Gatsby 9 | - Install demos and tools 10 | - Get people downloading the Gatsby demo app and NVDA if on Windows 11 | - Gatsby Demo app 12 | - Make sure everyone can install it and run the tests 13 | - Do a quick tour of the source code 14 | - Browser extensions / common testing tools 15 | - Accessibility debugging: using some of the intro examples 16 | - Rendering code in a browser for basic accessibility testing (quick explanation of web context / DOM vs. Node.js tooling) 17 | - Prototyping: budo 18 | - Gatsby demo app: starting a server with a bigger project 19 | - Discuss other rendering/localhost options, like http-server and Vue/Angular-specific things 20 | - Using the TAB key to check for keyboard support. 21 | - Apple OS setting (a common gotcha) 22 | - Visible focus outlines 23 | - What shouldn’t be interactive/operable with TAB? 24 | - EXERCISE: TAB through some webpages. Can you see where you are on the screen? Can you reach everything? 25 | - Known patterns / ARIA Authoring Practices Guide 26 | - Promote user testing with people with disabilities early and often 27 | - Chrome devtools color contrast debugger 28 | - Testing with the axe browser extensions for Chrome and Firefox 29 | - EXERCISE: Run an extension on a site 30 | - Screen reader testing overview 31 | - Recommended combinations: Voiceover and Safari, NVDA and Firefox, JAWS and IE11, iOS Safari + Voiceover, Android Firefox + Talkback, Narrator and Edge 32 | - Provide resources to learn more (cheat-sheets, training, etc.) 33 | - EXERCISE: use a screen reader (Mac Voiceover, iOS or Android, NVDA or Narrator for Windows) 34 | - Introduction to accessibility in JavaScript applications 35 | - Myth busting: accessibility + JavaScript can be BFFs. 36 | - Focus management requirements + patterns 37 | - Discuss focus manager APIs vs. components handling their own focus 38 | - Tabindex=”-1” vs. tabindex=”0” 39 | - React refs / other framework APIs 40 | - Use cases: 41 | - View changes 42 | - Focus in new layers, replacing on close 43 | - Disabling background layers 44 | - Handling removal of DOM nodes 45 | - Note known issues with iOS and other assistive technologies that can sometimes require workarounds (focus on buttons instead of headings, etc.) 46 | - Announcements using ARIA Live Regions 47 | - Use cases vary, there’s often more than one way to achieve something (discuss focus management vs. wrapping updating content in a live region, when you might not want to move focus, etc.) 48 | - Talk about Live Region requirements: must be rendered on page, but can be visually hidden 49 | - Sometimes two regions per “politeness” level are necessary to get everything to announce 50 | - A site-level announcement API might be the way to go 51 | - Async save / update / etc. 52 | - Title changes 53 | - Combobox usage / list filtering 54 | - Semantic HTML 55 | - Using semantics with JSX; verifying output 56 | - Putting landmarks into your app 57 | - Unintrusive Animation + prefers-reduced-motion 58 | - toggle button 59 | - OS-level setting 60 | - Progressive enhancement: accessible baselines, fallbacks, challenges with JS frameworks 61 | - Accessibility units: component-level API concerns 62 | - Automated unit testing approach 63 | - Test in isolation 64 | - Stub inputs/fixture data 65 | - Often headless 66 | - Fast changing of state 67 | - React Component examples with failing tests: combobox. 68 | - Accessible names (buttons, links, form controls) 69 | - TIPS: don’t use “click here” for links, consider adding visible labels for buttons 70 | - Keyboard interactions: escape key, arrow keys 71 | - Toggling ARIA states 72 | - EXERCISE: Choose a couple failing tests and make them pass 73 | - SOLUTION: Fix them together 74 | - Bonus info: configurable heading levels, passing in ARIA attributes with component DSLs (don’t make up ARIA attributes; use data attributes instead) etc. 75 | - Accessible pages 76 | - Integration/end-to-end testing approach 77 | - Real-world browser testing 78 | - Cypress vs. Selenium Webdriver 79 | - Document/page-level accessibility rules: page title, HTML lang, color contrast, heading hierarchy 80 | - Component interoperability 81 | - Framework testing flexibility 82 | - Accessibility testing APIs make more sense here as you can run an entire ruleset against each page 83 | - Gatsby example with failing tests 84 | - EXERCISE: Color contrast debugging with axe and Chrome devtools 85 | - EXERCISE: Fix failing focus management tests (showing component interop) 86 | - EXERCISE: get Cypress tests passing on a page 87 | - Q&A 88 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gatsby-a11y-workshop", 3 | "description": "Building Accessible Sites with Gatsby", 4 | "version": "1.0.1", 5 | "author": "Marcy Sutton ", 6 | "dependencies": { 7 | "@babel/plugin-proposal-export-default-from": "^7.2.0", 8 | "@emotion/core": "^10.0.14", 9 | "@emotion/styled": "^10.0.14", 10 | "autoprefixer": "^9.6.1", 11 | "downshift": "^3.2.10", 12 | "emotion": "^10.0.14", 13 | "extract-text-webpack-plugin": "^3.0.2", 14 | "focus-visible": "^5.0.2", 15 | "gatsby": "^2.19.12", 16 | "gatsby-image": "^2.2.6", 17 | "gatsby-plugin-manifest": "^2.2.1", 18 | "gatsby-plugin-mdx": "^1.0.7", 19 | "gatsby-plugin-offline": "^2.2.1", 20 | "gatsby-plugin-react-helmet": "^3.0.12", 21 | "gatsby-plugin-sass": "^2.1.3", 22 | "gatsby-plugin-sharp": "^2.2.7", 23 | "gatsby-remark-prismjs": "^3.3.3", 24 | "gatsby-source-filesystem": "^2.1.5", 25 | "gatsby-theme-mdx-deck": "^3.0.8", 26 | "gatsby-transformer-sharp": "^2.2.3", 27 | "match-sorter": "^4.0.1", 28 | "node-sass": "^4.12.0", 29 | "prop-types": "^15.7.2", 30 | "react": "^16.8.6", 31 | "react-debounce-input": "^3.2.0", 32 | "react-dom": "^16.8.6", 33 | "react-helmet": "^5.2.0", 34 | "sass-loader": "^7.1.0", 35 | "starwars-names": "^1.6.0" 36 | }, 37 | "keywords": [ 38 | "gatsby" 39 | ], 40 | "license": "MIT", 41 | "scripts": { 42 | "build": "gatsby build", 43 | "serve": "gatsby serve", 44 | "develop": "gatsby develop", 45 | "deploy": "gatsby clean && gatsby build --prefix-paths && gh-pages -d public", 46 | "start": "npm run develop", 47 | "format": "prettier --write \"src/**/*.js\"", 48 | "test": "jest", 49 | "cy:open": "cypress open", 50 | "cy:run": "cypress run", 51 | "test:e2e": "CYPRESS_SUPPORT=y start-server-and-test develop http://localhost:8000 cy:open", 52 | "test:e2e:ci": "start-server-and-test develop http://localhost:8000 cy:run" 53 | }, 54 | "devDependencies": { 55 | "@testing-library/cypress": "^4.0.5", 56 | "@testing-library/jest-dom": "^4.0.0", 57 | "@testing-library/react": "^8.0.6", 58 | "axe-core": "^3.3.1", 59 | "babel-jest": "^24.8.0", 60 | "babel-preset-gatsby": "^0.2.7", 61 | "cypress": "^3.4.1", 62 | "cypress-axe": "^0.5.1", 63 | "gh-pages": "^2.0.1", 64 | "identity-obj-proxy": "^3.0.0", 65 | "jest": "^24.8.0", 66 | "jest-transform-css": "^2.0.0", 67 | "prettier": "^1.17.0", 68 | "react-test-renderer": "^16.8.6", 69 | "start-server-and-test": "^1.9.1" 70 | }, 71 | "repository": { 72 | "type": "git", 73 | "url": "https://github.com/marcysutton/gatsby-a11y-workshop" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/__mocks__/file-mock.js: -------------------------------------------------------------------------------- 1 | module.exports = "test-file-stub" -------------------------------------------------------------------------------- /src/components/bad/animation.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState, useEffect } from "react" 2 | import styled from "@emotion/styled" 3 | 4 | const Demo = styled.div` 5 | .animationToggle { 6 | background-color: black; 7 | border: none; 8 | border-radius: 0; 9 | color: white; 10 | font-size: 1rem; 11 | padding: 0.5em 1em; 12 | } 13 | .animationTarget { 14 | height: 100%; 15 | margin: 1em auto; 16 | min-width: 400px; 17 | min-height: 400px; 18 | overflow: hidden; 19 | position: relative; 20 | width: 100%; 21 | } 22 | .pulse { 23 | z-index: 1; 24 | position: absolute; 25 | top: 50%; 26 | left: 50%; 27 | transform: translate(-50%, -50%); 28 | max-width: 30rem; 29 | 30 | circle { 31 | fill: #ff5154; 32 | transform: scale(0); 33 | opacity: 0.4; 34 | transform-origin: 50% 50%; 35 | animation: pulse 2.5s cubic-bezier(.5,.5,0,1) infinite; 36 | 37 | &:nth-child(2) { 38 | fill: #7fc6a4; 39 | animation: pulse 2.5s 0.75s cubic-bezier(.5,.5,0,1) infinite; 40 | } 41 | 42 | &:nth-child(3) { 43 | fill: #e5f77d; 44 | animation: pulse 2.5s 1.5s cubic-bezier(.5,.5,0,1) infinite; 45 | } 46 | 47 | } 48 | 49 | } 50 | 51 | @keyframes pulse { 52 | 25% { 53 | opacity: 0.4; 54 | } 55 | 56 | 100% { 57 | transform: scale(1); 58 | opacity: 0; 59 | } 60 | } 61 | ` 62 | 63 | const InaccessibleAnimationDemo = () => { 64 | return ( 65 | 66 |
70 | 71 | 72 | 73 | 74 | 75 |
76 |
77 | ) 78 | } 79 | 80 | export default InaccessibleAnimationDemo 81 | -------------------------------------------------------------------------------- /src/components/bad/async-form.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from "react" 2 | import {DebounceInput} from 'react-debounce-input' 3 | 4 | const InaccessibleAsyncFormDemo = () => { 5 | const [updating, setUpdating] = useState(false) 6 | 7 | const handleSubmit = (event) => { 8 | event.preventDefault() 9 | } 10 | const handleTextChange = (value) => { 11 | setUpdating(true) 12 | 13 | setTimeout(() => { 14 | setUpdating(false) 15 | }, 3000 16 | ) 17 | } 18 | return ( 19 |
20 |
21 | Enter text here
22 |