├── documentation ├── static │ ├── .nojekyll │ ├── fonts │ │ ├── bold │ │ │ ├── IBMPlexMono-Bold.woff │ │ │ └── IBMPlexMono-Bold.woff2 │ │ └── regular │ │ │ ├── IBMPlexMono-Regular.woff │ │ │ └── IBMPlexMono-Regular.woff2 │ └── img │ │ └── favicon.svg ├── docs │ ├── testing │ │ ├── demos │ │ │ ├── index.tsx │ │ │ └── styles.module.scss │ │ ├── _category_.json │ │ └── keyboard-only.mdx │ ├── hooks │ │ ├── _category_.json │ │ ├── demos │ │ │ ├── index.tsx │ │ │ └── useTabbable │ │ │ │ ├── index.modules.scss │ │ │ │ └── index.tsx │ │ └── use-tabbable.mdx │ ├── feedback │ │ ├── _category_.json │ │ ├── demos │ │ │ ├── index.tsx │ │ │ └── messages-announcer │ │ │ │ ├── styles.module.scss │ │ │ │ └── index.tsx │ │ └── messages-announcer.mdx │ ├── manage-focus │ │ ├── _category_.json │ │ ├── demos │ │ │ ├── index.tsx │ │ │ └── focus-manager.tsx │ │ └── rover-provider.mdx │ ├── content-and-navigation │ │ ├── _category_.json │ │ ├── demos │ │ │ ├── index.tsx │ │ │ ├── mocks.ts │ │ │ ├── visually-hidden.tsx │ │ │ ├── semantic-headings.tsx │ │ │ ├── skip-links.tsx │ │ │ └── styles.module.scss │ │ ├── semantic-headings.mdx │ │ ├── visually-hidden.mdx │ │ └── skip-links.mdx │ └── getting-started.mdx ├── babel.config.js ├── src │ ├── components │ │ ├── index.tsx │ │ ├── subtitle │ │ │ ├── index.module.css │ │ │ └── index.tsx │ │ ├── props-table │ │ │ ├── styles.module.scss │ │ │ ├── index.tsx │ │ │ └── useDynamicImport.ts │ │ └── preview │ │ │ ├── index.tsx │ │ │ └── styles.module.scss │ └── css │ │ └── custom.css ├── tsconfig.json ├── .gitignore ├── sidebars.ts ├── README.md ├── package.json └── docusaurus.config.ts ├── junit.xml ├── src ├── components │ ├── keyboard-only │ │ ├── styles.css │ │ └── index.tsx │ ├── focus-manager │ │ ├── index.ts │ │ ├── helpers │ │ │ └── index.ts │ │ ├── FocusManager.tsx │ │ ├── types.ts │ │ └── useFocusManager.tsx │ ├── semantic-headings │ │ ├── constants.ts │ │ ├── context.tsx │ │ ├── useHeadings.tsx │ │ ├── helpers.ts │ │ └── index.tsx │ ├── announcer │ │ ├── messages │ │ │ ├── index.tsx │ │ │ ├── context.tsx │ │ │ ├── useMessagesAnnouncer.tsx │ │ │ ├── consumer.tsx │ │ │ └── provider.tsx │ │ ├── announcer.tsx │ │ └── route-announcer │ │ │ └── index.tsx │ ├── index.ts │ ├── roving-tabindex │ │ ├── rover-provider │ │ │ ├── context.ts │ │ │ ├── consumer.tsx │ │ │ └── provider.tsx │ │ ├── use-focus-effect.ts │ │ └── index.ts │ ├── skip-links │ │ ├── index.module.scss │ │ ├── index.tsx │ │ └── link.tsx │ └── visually-hidden │ │ └── index.tsx ├── typings │ ├── index.ts │ ├── common.ts │ └── polymorphic.ts ├── helpers │ ├── index.ts │ ├── run-after-transition.ts │ └── focus-without-scrolling.ts ├── hooks │ ├── index.ts │ ├── useFocusVisible │ │ └── types.ts │ ├── useFocusWithin │ │ ├── types.ts │ │ └── index.ts │ ├── useDisableEvent.ts │ └── useTabbable.ts ├── index.ts └── global.d.ts ├── commitlint.config.cjs ├── .prettierrc ├── cypress ├── fixtures │ └── example.json ├── test │ ├── components │ │ ├── focus-manager │ │ │ └── demos │ │ │ │ ├── index.ts │ │ │ │ ├── MultipleManagers.module.scss │ │ │ │ ├── RestoreFocus.tsx │ │ │ │ └── MultipleManagers.tsx │ │ ├── keyboard-only │ │ │ └── index.spec.tsx │ │ ├── roving-tabindex │ │ │ └── use-focus-effect.spec.tsx │ │ ├── semantic-heading │ │ │ └── index.spec.tsx │ │ ├── announcer │ │ │ └── messages-announcer.spec.tsx │ │ ├── visually-hidden │ │ │ └── index.spec.tsx │ │ └── skip-links │ │ │ └── index.spec.tsx │ ├── helpers │ │ └── renderWithRouter.tsx │ └── hooks │ │ ├── use-focus.spec.tsx │ │ └── useTabbable.spec.tsx ├── tsconfig.json ├── support │ ├── e2e.ts │ ├── component.ts │ ├── component-index.html │ ├── hacks.ts │ ├── a11y │ │ ├── index.ts │ │ ├── injectAxe.ts │ │ ├── configureAxe.ts │ │ ├── assertions │ │ │ └── isAriaDisabled.ts │ │ └── checkA11y.ts │ └── commands.ts ├── selectors │ └── focusable.js └── e2e │ ├── skip-links.cy.tsx │ ├── focus-manager.cy.tsx │ ├── roving-tabindex.cy.tsx │ └── announcer.cy.tsx ├── .eslintignore ├── config └── setupVitest.ts ├── tsconfig.node.json ├── cypress.d.ts ├── .github ├── workflows │ ├── size.yml │ └── main.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── .editorconfig ├── .releaserc.json ├── cypress.config.ts ├── LICENSE ├── tsconfig.json ├── .eslintrc ├── .gitignore ├── vite.config.ts ├── test └── vitest │ └── use-focus-visible.test.tsx └── CHANGELOG.md /documentation/static/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /junit.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /documentation/docs/testing/demos/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./keyboard-only"; 2 | -------------------------------------------------------------------------------- /documentation/docs/hooks/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Hooks", 3 | "position": 6 4 | } 5 | -------------------------------------------------------------------------------- /documentation/docs/feedback/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Feedback", 3 | "position": 2 4 | } 5 | -------------------------------------------------------------------------------- /documentation/docs/manage-focus/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Focus", 3 | "position": 3 4 | } 5 | -------------------------------------------------------------------------------- /documentation/docs/testing/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Testing Utilities", 3 | "position": 4 4 | } 5 | -------------------------------------------------------------------------------- /documentation/docs/content-and-navigation/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Content and Navigation", 3 | "position": 5 4 | } 5 | -------------------------------------------------------------------------------- /documentation/docs/feedback/demos/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./messages-announcer"; 2 | export * from "./route-announcer"; 3 | -------------------------------------------------------------------------------- /documentation/docs/manage-focus/demos/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./focus-manager"; 2 | export * from "./roving-tab-index"; 3 | -------------------------------------------------------------------------------- /documentation/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /src/components/keyboard-only/styles.css: -------------------------------------------------------------------------------- 1 | .fdz-css-no-mouse *, 2 | .fdz-css-no-mouse *:hover { 3 | cursor: none !important; 4 | } 5 | -------------------------------------------------------------------------------- /documentation/src/components/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./preview"; 2 | export * from "./subtitle"; 3 | export * from "./props-table"; 4 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | const COMMITLINT_CONFIG = { 2 | extends: ["@commitlint/config-conventional"], 3 | }; 4 | 5 | module.exports = COMMITLINT_CONFIG; 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": false, 4 | "semi": true, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "useTabs": true 8 | } 9 | -------------------------------------------------------------------------------- /documentation/static/fonts/bold/IBMPlexMono-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feedzai/react-a11y-tools/HEAD/documentation/static/fonts/bold/IBMPlexMono-Bold.woff -------------------------------------------------------------------------------- /documentation/static/fonts/bold/IBMPlexMono-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feedzai/react-a11y-tools/HEAD/documentation/static/fonts/bold/IBMPlexMono-Bold.woff2 -------------------------------------------------------------------------------- /documentation/docs/content-and-navigation/demos/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./skip-links"; 2 | export * from "./semantic-headings"; 3 | export * from "./visually-hidden"; 4 | -------------------------------------------------------------------------------- /documentation/static/fonts/regular/IBMPlexMono-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feedzai/react-a11y-tools/HEAD/documentation/static/fonts/regular/IBMPlexMono-Regular.woff -------------------------------------------------------------------------------- /documentation/static/fonts/regular/IBMPlexMono-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feedzai/react-a11y-tools/HEAD/documentation/static/fonts/regular/IBMPlexMono-Regular.woff2 -------------------------------------------------------------------------------- /documentation/src/components/subtitle/index.module.css: -------------------------------------------------------------------------------- 1 | .subtitle { 2 | margin: 0 0 1.5rem 0; 3 | font-weight: 400; 4 | border-bottom: none; 5 | font-size: 1.25rem; 6 | line-height: 1.75; 7 | } 8 | -------------------------------------------------------------------------------- /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 | } 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Ignore node_modules 2 | node_modules/ 3 | 4 | # Ignore build files 5 | dist/ 6 | 7 | # Ignore styleguide configuration file 8 | styleguide.config.js 9 | 10 | # Ignore development files 11 | *.css 12 | -------------------------------------------------------------------------------- /documentation/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This file is not used in compilation. It is here just for a nice editor experience. 3 | "extends": "@docusaurus/tsconfig", 4 | "compilerOptions": { 5 | "baseUrl": "." 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /documentation/src/components/subtitle/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styles from "./index.module.css"; 3 | 4 | export const Subtitle: React.FC = ({ children }) => { 5 | return

{children}

; 6 | } 7 | -------------------------------------------------------------------------------- /cypress/test/components/focus-manager/demos/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2024 Feedzai, Rights Reserved. 6 | */ 7 | export * from "./MultipleManagers"; 8 | export * from "./RestoreFocus"; 9 | -------------------------------------------------------------------------------- /config/setupVitest.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom"; 6 | -------------------------------------------------------------------------------- /src/components/focus-manager/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2024 Feedzai, Rights Reserved. 6 | */ 7 | export * from "./FocusManager"; 8 | export * from "./types"; 9 | export * from "./useFocusManager"; 10 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "sourceMap": false, 6 | "types": ["node", "cypress", "cypress-real-events", "@testing-library/cypress"] 7 | }, 8 | "include": ["../cypress.d.ts", "**/*.js", "**/*.jsx", "**/*.tsx", "**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /documentation/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /src/typings/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license agreement. 3 | * 4 | * (c) 2021 Feedzai, Rights Reserved. 5 | */ 6 | 7 | /** 8 | * index.ts 9 | * 10 | * General Typings 11 | * 12 | * @author João Dias 13 | * @since 1.1.0 14 | */ 15 | export * from "./common"; 16 | export * from "./polymorphic"; 17 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | 8 | /** 9 | * index.js 10 | * 11 | * @author João Dias 12 | * @since 1.0.0 13 | */ 14 | import "@cypress/code-coverage/support"; 15 | import "./commands"; 16 | import "./hacks"; 17 | -------------------------------------------------------------------------------- /cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | import "@cypress/code-coverage/support"; 2 | import "./commands"; 3 | import "./hacks"; 4 | import "../../src/components/skip-links/index.module.scss"; 5 | import { mount, unmount } from "cypress/react"; 6 | 7 | // Cypress component testing methods 8 | Cypress.Commands.add("mount", mount); 9 | Cypress.Commands.add("unmount", unmount); 10 | -------------------------------------------------------------------------------- /cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Components App 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* 3 | * Please refer to the terms of the license 4 | * agreement. 5 | * 6 | * (c) 2021 Feedzai, Rights Reserved. 7 | */ 8 | 9 | /** 10 | * index.ts 11 | * 12 | * @author João Dias 13 | * @since 1.0.0 14 | */ 15 | export * from "./run-after-transition"; 16 | export { focusWithoutScrolling } from "./focus-without-scrolling"; 17 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | 8 | /** 9 | * index.ts 10 | * 11 | * @author João Dias 12 | * @since 1.0.0 13 | */ 14 | export * from "./useDisableEvent"; 15 | export * from "./useFocusVisible"; 16 | export * from "./useFocusWithin"; 17 | export * from "./useTabbable"; 18 | -------------------------------------------------------------------------------- /cypress.d.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "cypress/react"; 2 | 3 | // Augment the Cypress namespace to include type definitions for 4 | // your custom command. 5 | // Alternatively, can be defined in cypress/support/component.d.ts 6 | // with a at the top of your spec. 7 | declare global { 8 | namespace Cypress { 9 | interface Chainable { 10 | mount: typeof mount; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /documentation/src/components/props-table/styles.module.scss: -------------------------------------------------------------------------------- 1 | .table { 2 | border-collapse: collapse; 3 | border-spacing: 0; 4 | font-size: 0.875rem; 5 | line-height: 1.5; 6 | text-align: left; 7 | width: 100%; 8 | 9 | th, 10 | td { 11 | border: none; 12 | } 13 | 14 | thead tr { 15 | border-top-color: transparent; 16 | border-bottom-color: transparent; 17 | } 18 | 19 | code { 20 | padding: 4px; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* 3 | * Please refer to the terms of the license 4 | * agreement. 5 | * 6 | * (c) 2021 Feedzai, Rights Reserved. 7 | */ 8 | 9 | /** 10 | * index.tsx 11 | * 12 | * @author João Dias 13 | * @since 1.0.0 14 | */ 15 | export * from "./components"; 16 | export * from "./helpers"; 17 | export { useDisableEvent, useFocusVisible, useFocusWithin, useTabbable } from "./hooks"; 18 | -------------------------------------------------------------------------------- /src/components/semantic-headings/constants.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license agreement. 3 | * 4 | * (c) 2021 Feedzai, Rights Reserved. 5 | */ 6 | 7 | /** 8 | * constants.ts 9 | * 10 | * description 11 | * 12 | * @author João Dias 13 | * @since 1.0.0 14 | */ 15 | export const FIRST_HEADING_LEVEL = 1; 16 | export const LAST_HEADING_LEVEL = 6; 17 | export const HEADINGS_SELECTOR = "h1,h2,h3,h4,h5,h6"; 18 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | name: Checking bundle size 6 | runs-on: ${{ matrix.os }} 7 | strategy: 8 | matrix: 9 | node-version: ["18.x"] 10 | os: [ubuntu-latest] 11 | env: 12 | CI_JOB_NUMBER: 1 13 | steps: 14 | - uses: actions/checkout@v1 15 | - uses: andresz1/size-limit-action@v1 16 | with: 17 | github_token: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /src/components/semantic-headings/context.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license agreement. 3 | * 4 | * (c) 2021 Feedzai, Rights Reserved. 5 | */ 6 | 7 | /** 8 | * context.tsx 9 | * 10 | * @author João Dias 11 | * @since 1.0.0 12 | */ 13 | import { createContext } from "react"; 14 | import { FIRST_HEADING_LEVEL } from "./constants"; 15 | 16 | /** 17 | * Headings Context, with the initial value of 1. 18 | */ 19 | export const HeadingsContext = createContext(FIRST_HEADING_LEVEL); 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*.md] 7 | trim_trailing_whitespace = false 8 | 9 | [*.mdx] 10 | trim_trailing_whitespace = false 11 | 12 | [*.js, *.ts, *.tsx, *.jsx] 13 | trim_trailing_whitespace = true 14 | quote_type = "single" 15 | curly_bracket_next_line = "true" 16 | 17 | # Unix-style newlines with a newline ending every file 18 | [*] 19 | indent_style = tab 20 | indent_size = 4 21 | end_of_line = lf 22 | insert_final_newline = true 23 | 24 | charset = utf-8 25 | max_line_length = 100 26 | -------------------------------------------------------------------------------- /documentation/docs/hooks/demos/index.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * The copyright of this file belongs to Feedzai. The file cannot be 3 | * reproduced in whole or in part, stored in a retrieval system, transmitted 4 | * in any form, or by any means electronic, mechanical, or otherwise, without 5 | * the prior permission of the owner. Please refer to the terms of the license 6 | * agreement. 7 | * 8 | * (c) 2021 Feedzai, Rights Reserved. 9 | */ 10 | 11 | /** 12 | * index.tsx 13 | * 14 | * @author João Dias 15 | * @since ```feedzai.next.release``` 16 | */ 17 | export * from "./useTabbable"; 18 | -------------------------------------------------------------------------------- /cypress/selectors/focusable.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | export const FOCUSABLE_HTML_ELEMENTS = [ 8 | "input:not([disabled]):not([type=hidden])", 9 | "select:not([disabled])", 10 | "textarea:not([disabled])", 11 | "button:not([disabled])", 12 | "a[href]", 13 | "area[href]", 14 | "summary", 15 | "iframe", 16 | "object", 17 | "embed", 18 | "audio[controls]", 19 | "video[controls]", 20 | "[contenteditable]", 21 | ]; 22 | 23 | export const FOCUSABLE_ELEMENT_SELECTOR = FOCUSABLE_HTML_ELEMENTS.join(",") + ",[tabindex]"; 24 | -------------------------------------------------------------------------------- /cypress/test/helpers/renderWithRouter.tsx: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* 3 | * Please refer to the terms of the license 4 | * agreement. 5 | * 6 | * (c) 2021 Feedzai, Rights Reserved. 7 | */ 8 | import React from "react"; 9 | import { MemoryRouter } from "react-router-dom"; 10 | 11 | /** 12 | * Renders a component wrapped inside a `@reach/router` instance. 13 | * Adds `history` to the returned utilities to allow us to reference it in our tests 14 | * 15 | * @param {React.ReactElement} ui 16 | */ 17 | export function renderWithRouter( 18 | ui: React.ReactElement, 19 | ) { 20 | cy.mount({ui}); 21 | } 22 | -------------------------------------------------------------------------------- /documentation/docs/content-and-navigation/semantic-headings.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | title: Semantic Headings 4 | --- 5 | 6 | import { DemoSemanticHeadings } from "./demos"; 7 | import { Subtitle, BrowserWindow, PropsTable } from "../../src/components/index"; 8 | 9 | 10 | Maintain the proper hierarchy of headings for improved accessibility, no matter the component 11 | structure. 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | ### Props 20 | 21 | #### Level 22 | 23 | 24 | 25 | #### Level 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/helpers/run-after-transition.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* 3 | * Please refer to the terms of the license 4 | * agreement. 5 | * 6 | * (c) 2021 Feedzai, Rights Reserved. 7 | */ 8 | 9 | /** 10 | * run-after-transition.ts 11 | * 12 | * @author João Dias 13 | * @since 1.0.0 14 | */ 15 | const transitionsByElement = new Map>(); 16 | const transitionCallbacks = new Set<() => void>(); 17 | 18 | export function runAfterTransition(fn: () => void): void { 19 | requestAnimationFrame(() => { 20 | if (transitionsByElement.size === 0) { 21 | fn(); 22 | } else { 23 | transitionCallbacks.add(fn); 24 | } 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /documentation/docs/content-and-navigation/visually-hidden.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | title: Visually Hidden 4 | --- 5 | 6 | import { DemoVisuallyHidden } from "./demos"; 7 | import { Subtitle, BrowserWindow, PropsTable } from "../../src/components/index"; 8 | 9 | Hides its children visually, while keeping content visible to screen readers. 10 | 11 | 12 | 13 | 14 |
15 | 16 | Visually hidden is a common technique used in web accessibility to hide content from the visual client, but keep it readable for screen readers. 17 | This component renders html `` as a proxy. 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/components/announcer/messages/index.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | 8 | /** 9 | * index.tsx 10 | * 11 | * @author João Dias 12 | * @since 1.0.0 13 | */ 14 | import type { Dispatch } from "react"; 15 | export interface Announcement { 16 | message: string; 17 | politeness?: "assertive" | "polite"; 18 | } 19 | 20 | export type AnnouncementContext = Announcement & { 21 | setMessage: Dispatch; 22 | }; 23 | 24 | export type AnnouncementReducerState = Announcement; 25 | 26 | export * from "./provider"; 27 | export * from "./consumer"; 28 | export * from "./useMessagesAnnouncer"; 29 | -------------------------------------------------------------------------------- /src/components/announcer/messages/context.tsx: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* 3 | * Please refer to the terms of the license 4 | * agreement. 5 | * 6 | * (c) 2021 Feedzai, Rights Reserved. 7 | */ 8 | 9 | /** 10 | * context.tsx 11 | * 12 | * @author João Dias 13 | * @since 1.0.0 14 | */ 15 | import { createContext } from "react"; 16 | import { AnnouncementContext } from "./index"; 17 | 18 | export const defaultState: AnnouncementContext = { 19 | message: "", 20 | politeness: "polite", 21 | // eslint-disable-next-line @typescript-eslint/no-empty-function 22 | setMessage: () => {}, 23 | }; 24 | 25 | export const MessagesAnnouncerContext = createContext(defaultState); 26 | -------------------------------------------------------------------------------- /cypress/test/components/keyboard-only/index.spec.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /* 4 | * Please refer to the terms of the license agreement. 5 | * 6 | * (c) 2021 Feedzai, Rights Reserved. 7 | */ 8 | 9 | /** 10 | * index.test.tsx 11 | * 12 | * @author João Dias 13 | * @since 1.0.0 14 | */ 15 | import React from "react"; 16 | import { AuditInPage } from "./mocks/AuditInPage"; 17 | 18 | describe("", () => { 19 | it("should render the KeyboardOnly component", () => { 20 | cy.mount(); 21 | 22 | cy.findByTestId("fdz-js-audit").should("have.class", ""); 23 | cy.root().should("have.class", "fdz-css-no-mouse"); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /cypress/support/hacks.ts: -------------------------------------------------------------------------------- 1 | const RESIZE_OBSERVER_LOOP_ERROR = /^[^(ResizeObserver loop limit exceeded)]/; 2 | 3 | /** 4 | * When an exception has origin on an unhandled promise rejection, 5 | * that promise is provided as a third argument. 6 | * On those cases, we can turn the failing off. 7 | */ 8 | function handleOnUncaughtException(error: Error, _: Mocha.Runnable, promise: Promise) { 9 | const isResizeObserverLoop = RESIZE_OBSERVER_LOOP_ERROR.test(error.message); 10 | const isUnhandledPromiseRejection = promise; 11 | 12 | if (isResizeObserverLoop || isUnhandledPromiseRejection) { 13 | return false; 14 | } 15 | } 16 | 17 | // eslint-disable-next-line consistent-return 18 | Cypress.on("uncaught:exception", handleOnUncaughtException); 19 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "main", 4 | { 5 | "name": "next", 6 | "prerelease": true 7 | } 8 | ], 9 | "debug": true, 10 | "ci": true, 11 | "dryRun": false, 12 | "plugins": [ 13 | ["@semantic-release/commit-analyzer"], 14 | ["@semantic-release/release-notes-generator"], 15 | [ 16 | "@semantic-release/changelog", 17 | { 18 | "changelogFile": "CHANGELOG.md" 19 | } 20 | ], 21 | "@semantic-release/npm", 22 | "@semantic-release/github", 23 | [ 24 | "@semantic-release/git", 25 | { 26 | "assets": ["package.json", "package-lock.json", "./CHANGELOG.md"], 27 | "message": "chore(release): set `package.json` to ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 28 | } 29 | ] 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /documentation/docs/content-and-navigation/skip-links.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | title: Skip Links 4 | --- 5 | 6 | import { DemoSkipLinks } from "./demos"; 7 | import { Subtitle, BrowserWindow, PropsTable } from "../../src/components/index"; 8 | 9 | 10 | Provide ways for users to skip to important sections of our websites. It helps users using 11 | screen-readers to navigate easier and more efficiently. 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | :::note How to 20 | 21 | - Press the `tab` key 22 | - A skip link will be shown on the top of the demo. 23 | - Press `enter` 24 | - Focus will be set on the area of that `SkipLink`. 25 | 26 | ::: 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/components/announcer/messages/useMessagesAnnouncer.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | 8 | /** 9 | * useMessagesAnnouncer.tsx 10 | * 11 | * @author João Dias 12 | * @since 1.0.0 13 | */ 14 | import { useContext } from "react"; 15 | import { MessagesAnnouncerContext } from "./context"; 16 | import { AnnouncementContext } from "./index"; 17 | 18 | /** 19 | * Hook for sending messages to the `MessagesAnnouncer` context provider 20 | * 21 | * @returns {IMessagesAnnouncerContext} 22 | */ 23 | export function useMessagesAnnouncer(): AnnouncementContext { 24 | const context = useContext(MessagesAnnouncerContext); 25 | return context; 26 | } 27 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | export { 8 | MessagesAnnouncer, 9 | MessagesAnnouncerConsumer, 10 | useMessagesAnnouncer, 11 | } from "./announcer/messages/index"; 12 | export { RouteAnnouncer } from "./announcer/route-announcer"; 13 | export { KeyboardOnly } from "./keyboard-only"; 14 | export * from "./focus-manager/index"; 15 | export { Heading, Level, Level as HeadingLevel, useHeadings } from "./semantic-headings"; 16 | export { SkipLinks } from "./skip-links"; 17 | export { 18 | RoverConsumer, 19 | RoverProvider, 20 | RoverContext, 21 | useFocus, 22 | useRover, 23 | } from "./roving-tabindex/index"; 24 | export { VisuallyHidden, visuallyHiddenStyle } from "./visually-hidden"; 25 | -------------------------------------------------------------------------------- /src/components/roving-tabindex/rover-provider/context.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* 3 | * Please refer to the terms of the license 4 | * agreement. 5 | * 6 | * (c) 2021 Feedzai, Rights Reserved. 7 | */ 8 | 9 | /** 10 | * context.ts 11 | * 12 | * @author João Dias 13 | * @since 1.0.0 14 | */ 15 | import { createContext } from "react"; 16 | import { emptyFunction } from "@feedzai/js-utilities"; 17 | import { RovingContext, RovingState } from "../index"; 18 | 19 | export const initialState: RovingState = { 20 | direction: "horizontal", 21 | selectedId: null, 22 | lastActionOrigin: null, 23 | tabStops: [], 24 | }; 25 | 26 | export const RoverContext = createContext({ 27 | state: { 28 | ...initialState, 29 | }, 30 | dispatch: emptyFunction, 31 | }); 32 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import { defineConfig } from "cypress"; 3 | const codeCoverageTask = require('@cypress/code-coverage/task') 4 | 5 | export default defineConfig({ 6 | viewportWidth: 1280, 7 | viewportHeight: 800, 8 | retries: { 9 | runMode: 2, 10 | }, 11 | video: false, 12 | e2e: { 13 | baseUrl: "http://localhost:3000/react-a11y-tools", 14 | setupNodeEvents(on, config) { 15 | codeCoverageTask(on, config); 16 | 17 | return config; 18 | }, 19 | }, 20 | component: { 21 | specPattern: "cypress/test/**/*.spec.{js,jsx,ts,tsx}", 22 | devServer: { 23 | framework: "react", 24 | bundler: "vite", 25 | }, 26 | setupNodeEvents(on, config) { 27 | codeCoverageTask(on, config); 28 | 29 | return config; 30 | }, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /documentation/static/img/favicon.svg: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /cypress/support/a11y/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * The copyright of this file belongs to Feedzai. The file cannot be 3 | * reproduced in whole or in part, stored in a retrieval system, transmitted 4 | * in any form, or by any means electronic, mechanical, or otherwise, without 5 | * the prior permission of the owner. Please refer to the terms of the license 6 | * agreement. 7 | * 8 | * (c) 2022 Feedzai, Rights Reserved. 9 | */ 10 | 11 | /** 12 | * index.ts 13 | * 14 | * @author João Dias 15 | * @since ```feedzai.next.release``` 16 | */ 17 | 18 | import checkA11y from "./checkA11y"; 19 | import configureAxe from "./configureAxe"; 20 | import injectAxe from "./injectAxe"; 21 | 22 | Cypress.Commands.add("injectAxe", injectAxe); 23 | Cypress.Commands.add("configureAxe", configureAxe); 24 | Cypress.Commands.add("checkA11y", checkA11y); 25 | -------------------------------------------------------------------------------- /documentation/docs/content-and-navigation/demos/mocks.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | 8 | /** 9 | * skip-links.mocks.tsx 10 | * 11 | * @author João Dias 12 | * @since 1.0.0 13 | */ 14 | import { ISkipLink } from "../../../../src/components/skip-links/link"; 15 | 16 | /** 17 | * @type {ILinkOption[]} options 18 | */ 19 | const single: ISkipLink[] = [ 20 | { 21 | target: "#content", 22 | text: "Skip to main content", 23 | }, 24 | ]; 25 | 26 | const multiple = [ 27 | { 28 | target: "#content", 29 | text: "Skip to main content", 30 | as: "button", 31 | }, 32 | { 33 | target: "#navigation-menu", 34 | text: "Go to navigation menu", 35 | as: "button", 36 | }, 37 | ]; 38 | 39 | export const options = { 40 | single, 41 | multiple, 42 | }; 43 | -------------------------------------------------------------------------------- /documentation/sidebars.ts: -------------------------------------------------------------------------------- 1 | import type {SidebarsConfig} from '@docusaurus/plugin-content-docs'; 2 | 3 | /** 4 | * Creating a sidebar enables you to: 5 | - create an ordered group of docs 6 | - render a sidebar for each doc of that group 7 | - provide next/previous navigation 8 | 9 | The sidebars can be generated from the filesystem, or explicitly defined here. 10 | 11 | Create as many sidebars as you want. 12 | */ 13 | const sidebars: SidebarsConfig = { 14 | // By default, Docusaurus generates a sidebar from the docs folder structure 15 | tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], 16 | 17 | // But you can create a sidebar manually 18 | /* 19 | tutorialSidebar: [ 20 | 'intro', 21 | 'hello', 22 | { 23 | type: 'category', 24 | label: 'Tutorial', 25 | items: ['tutorial-basics/create-a-document'], 26 | }, 27 | ], 28 | */ 29 | }; 30 | 31 | export default sidebars; 32 | -------------------------------------------------------------------------------- /documentation/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | Using SSH: 30 | 31 | ``` 32 | $ USE_SSH=true yarn deploy 33 | ``` 34 | 35 | Not using SSH: 36 | 37 | ``` 38 | $ GIT_USER= yarn deploy 39 | ``` 40 | 41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 42 | -------------------------------------------------------------------------------- /src/components/semantic-headings/useHeadings.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license agreement. 3 | * 4 | * (c) 2021 Feedzai, Rights Reserved. 5 | */ 6 | /** 7 | * useHeadings.tsx 8 | * 9 | * @author João Dias 10 | * @since 1.0.0 11 | */ 12 | 13 | import { useContext } from "react"; 14 | import { HeadingsContext } from "./context"; 15 | import { getHeadingLevel } from "./helpers"; 16 | 17 | /** 18 | * Custom Hook that returns the appropriate heading level to render onto the DOM 19 | * 20 | * @example 21 | * // Get the current heading level 22 | * const level = useHeadings(); 23 | * 24 | * // Render a custom heading element 25 | * const CustomHeading = `h${level}`; 26 | * 27 | * return ( 28 | * a title 29 | * ); 30 | * 31 | * @returns {number} 32 | */ 33 | export function useHeadings(): number { 34 | const contextLevel = useContext(HeadingsContext); 35 | 36 | return getHeadingLevel(contextLevel); 37 | } 38 | -------------------------------------------------------------------------------- /src/typings/common.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license agreement. 3 | * 4 | * (c) 2021 Feedzai, Rights Reserved. 5 | */ 6 | 7 | /** 8 | * common.ts 9 | * 10 | * Common extra typings for HTML Elements 11 | * 12 | * @author João Dias 13 | * @since 1.1.0 14 | */ 15 | 16 | type AsProp = { 17 | /** 18 | * An override of the default HTML tag. 19 | * Can also be another React component. 20 | */ 21 | as?: GenericComponent; 22 | }; 23 | 24 | /** 25 | * Common HTML Element types. 26 | * 27 | * Accepts a type of element tag, like `"div"`, `"span"`, `"p"` 28 | */ 29 | export type CommonElement = 30 | React.HTMLAttributes & { 31 | /** 32 | * A `data-attribute` identifier for testing purposes 33 | * 34 | * @type {string} 35 | * @memberof CommonElement 36 | */ 37 | "data-testid"?: string; 38 | } & AsProp; 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /documentation/docs/content-and-navigation/demos/visually-hidden.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import { VisuallyHidden } from "../../../../src"; 3 | import styles from "./styles.module.scss"; 4 | 5 | const ELEMENT_ID = "064a70e8-5c1e-43e6-8eee-9ab069b094fc"; 6 | 7 | export const DemoVisuallyHidden = () => { 8 | const [buttonText, setButtonText] = useState(""); 9 | const ref = useRef(null); 10 | 11 | useEffect(() => { 12 | if (ref.current && ref.current.textContent) { 13 | setButtonText(ref.current.textContent); 14 | } 15 | }, [setButtonText]); 16 | return ( 17 | <> 18 | 27 |
28 |
29 |
30 |

Button content:

31 |

"{buttonText}"

32 |
33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/semantic-headings/helpers.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license agreement. 3 | * 4 | * (c) 2021 Feedzai, Rights Reserved. 5 | */ 6 | 7 | /** 8 | * helpers.ts 9 | * 10 | * @author João Dias 11 | * @since 1.0.0 12 | */ 13 | import { inRange } from "@feedzai/js-utilities"; 14 | import { FIRST_HEADING_LEVEL, LAST_HEADING_LEVEL } from "./constants"; 15 | 16 | /** 17 | * Checks if the heading level is within range and valid. 18 | * If not: 19 | * 1.1. Outputs a warning to the browser's console (during development) 20 | * 1.2. and returns a value between the range (i.e: if invalid h7, then returns 6) 21 | * 22 | * @param {number} level 23 | * @returns {number} 24 | */ 25 | export function getHeadingLevel(level: number): number { 26 | const isWithinRange = inRange(level, FIRST_HEADING_LEVEL, LAST_HEADING_LEVEL); 27 | 28 | switch (true) { 29 | case isWithinRange: 30 | return level; 31 | 32 | default: 33 | return Math.min(Math.max(FIRST_HEADING_LEVEL, level), LAST_HEADING_LEVEL); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/keyboard-only/index.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | 8 | /** 9 | * index.tsx 10 | * 11 | * @author João Dias 12 | * @since 1.0.0 13 | */ 14 | import React, { FunctionComponent } from "react"; 15 | import { useSafeLayoutEffect } from "@feedzai/js-utilities/hooks"; 16 | import "./styles.css"; 17 | 18 | const addClass = (className: string) => document.documentElement.classList.add(className); 19 | const removeClass = (className: string) => document.documentElement.classList.remove(className); 20 | 21 | /** 22 | * Accessibility Keyboard-only component 23 | * Eables the developer to use the interface without a mouse pointer. 24 | * 25 | * @param {IAuditProps} props 26 | */ 27 | export const KeyboardOnly: FunctionComponent = () => { 28 | useSafeLayoutEffect(() => { 29 | addClass("fdz-css-no-mouse"); 30 | 31 | return () => { 32 | removeClass("fdz-css-no-mouse"); 33 | }; 34 | }, []); 35 | 36 | return
; 37 | }; 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Feedzai 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. -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "allowImportingTsExtensions": true, 5 | "allowJs": false, 6 | "baseUrl": ".", 7 | "declaration": true, 8 | "downlevelIteration": true, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "jsx": "react", 14 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 15 | "module": "ESNext", 16 | "moduleResolution": "Bundler", 17 | "noEmit": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "noImplicitReturns": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "removeComments": false, 23 | "resolveJsonModule": true, 24 | "skipLibCheck": true, 25 | "sourceMap": true, 26 | "strict": true, 27 | "target": "ESNext", 28 | "useDefineForClassFields": true, 29 | "paths": { 30 | "src/*": ["./src/*"] 31 | }, 32 | "types": ["node", "@testing-library/jest-dom"] 33 | }, 34 | "include": ["src", "global.d.ts"], 35 | "exclude": ["node_modules", "dist"], 36 | "references": [{ "path": "./tsconfig.node.json" }] 37 | } 38 | -------------------------------------------------------------------------------- /cypress/test/components/focus-manager/demos/MultipleManagers.module.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * The copyright of this file belongs to Feedzai. The file cannot be 3 | * reproduced in whole or in part, stored in a retrieval system, transmitted 4 | * in any form, or by any means electronic, mechanical, or otherwise, without 5 | * the prior permission of the owner. Please refer to the terms of the license 6 | * agreement. 7 | * 8 | * (c) 2024 Feedzai, Rights Reserved. 9 | */ 10 | .table { 11 | font-family: arial, sans-serif; 12 | border-collapse: collapse; 13 | width: 100%; 14 | td, 15 | th { 16 | border: 1px solid #dddddd; 17 | text-align: left; 18 | padding: 8px; 19 | } 20 | 21 | tr:nth-child(even) { 22 | background-color: #dddddd; 23 | } 24 | 25 | tr:focus-within { 26 | outline: 2px solid blue; 27 | background-color: lightblue; 28 | } 29 | } 30 | 31 | .dialog { 32 | position: absolute; 33 | inset: 0; 34 | margin: auto; 35 | max-width: 50vw; 36 | display: grid; 37 | place-items: center; 38 | background-color: white; 39 | outline: 2px solid black; 40 | height: 50vh; 41 | 42 | &:focus-within { 43 | outline-color: blue; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /documentation/src/components/preview/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import styles from "./styles.module.scss"; 3 | 4 | interface Props { 5 | children: ReactNode; 6 | minHeight: number; 7 | url: string; 8 | } 9 | 10 | export function BrowserWindow({ children, minHeight }: Props) { 11 | return ( 12 |
18 |
19 |
20 | 21 | 22 | 23 |
24 |
25 |
26 |
27 | 28 | 29 | 30 |
31 |
32 |
33 | 34 |
{children}
35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/hooks/useFocusVisible/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * The copyright of this file belongs to Feedzai. The file cannot be 3 | * reproduced in whole or in part, stored in a retrieval system, transmitted 4 | * in any form, or by any means electronic, mechanical, or otherwise, without 5 | * the prior permission of the owner. Please refer to the terms of the license 6 | * agreement. 7 | * 8 | * (c) 2022 Feedzai, Rights Reserved. 9 | */ 10 | 11 | /** 12 | * types.ts 13 | * 14 | * @author João Dias 15 | * @since ```feedzai.next.release``` 16 | */ 17 | 18 | export type Modality = "keyboard" | "pointer" | "virtual"; 19 | export type HandlerEvent = PointerEvent | MouseEvent | KeyboardEvent | FocusEvent; 20 | export type Handler = (modality: Modality, event: HandlerEvent | null) => void; 21 | export type FocusVisibleHandler = (isFocusVisible: boolean) => void; 22 | export interface FocusVisibleProps { 23 | /** Whether the element is a text input. */ 24 | isTextInput?: boolean; 25 | /** Whether the element will be auto focused. */ 26 | autoFocus?: boolean; 27 | } 28 | 29 | export interface FocusVisibleResult { 30 | /** Whether keyboard focus is visible globally. */ 31 | isFocusVisible: boolean; 32 | } 33 | -------------------------------------------------------------------------------- /src/components/roving-tabindex/rover-provider/consumer.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | 8 | /** 9 | * consumer.tsx 10 | * 11 | * @author João Dias 12 | * @since 1.0.0 13 | */ 14 | import React from "react"; 15 | import { RovingAction, RovingState } from "../index"; 16 | import { RoverContext } from "./context"; 17 | 18 | /** 19 | * Rover Context Consumer 20 | * 21 | * A Context API wrapper suitable for class-based components, where `useRover` hook is not possible. 22 | * 23 | * @example 24 | * 25 | * {(state, dispatch) => { ...your content goes here }} 26 | * 27 | * 28 | * @export 29 | * @param {{ children: (state: RovingState, dispatch: React.Dispatch) => React.ReactElement}} { children } 30 | * @returns {React.ReactElement} 31 | */ 32 | export function RoverConsumer({ 33 | children, 34 | }: { 35 | children: (state: RovingState, dispatch: React.Dispatch) => React.ReactElement; 36 | }) { 37 | return ( 38 | 39 | {({ state, dispatch }) => children(state, dispatch)} 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/hooks/useFocusWithin/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * The copyright of this file belongs to Feedzai. The file cannot be 3 | * reproduced in whole or in part, stored in a retrieval system, transmitted 4 | * in any form, or by any means electronic, mechanical, or otherwise, without 5 | * the prior permission of the owner. Please refer to the terms of the license 6 | * agreement. 7 | * 8 | * (c) 2022 Feedzai, Rights Reserved. 9 | */ 10 | 11 | import { FocusEvent, HTMLAttributes } from "react"; 12 | 13 | /** 14 | * types.ts 15 | * 16 | * @author João Dias 17 | * @since ```feedzai.next.release``` 18 | */ 19 | export interface UseFocusWithinProps { 20 | /** Handler that is called when the target element or a descendant receives focus. */ 21 | onFocusWithin?: (e: FocusEvent) => void; 22 | /** Handler that is called when the target element and all descendants lose focus. */ 23 | onBlurWithin?: (e: FocusEvent) => void; 24 | /** Handler that is called when the the focus within state changes. */ 25 | onFocusWithinChange?: (isFocusWithin: boolean) => void; 26 | } 27 | 28 | export interface UseFocusWithinReturns { 29 | /** Props to spread onto the target element. */ 30 | focusWithinProps: HTMLAttributes; 31 | } 32 | -------------------------------------------------------------------------------- /src/hooks/useDisableEvent.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license agreement. 3 | * 4 | * (c) 2021 Feedzai, Rights Reserved. 5 | */ 6 | 7 | /** 8 | * useDisableEvent.js 9 | * 10 | * Disables an event bubbling up on a DOM element 11 | * 12 | * @author João Dias 13 | * @since 1.0.0 14 | */ 15 | import { useCallback } from "react"; 16 | 17 | export type UseDisableEventReturns = (event: React.SyntheticEvent) => void; 18 | 19 | /** 20 | * Disables an event bubbling up on a DOM element 21 | * 22 | * @export 23 | * @param {boolean} [disabled] 24 | * @returns {UseDisableEventReturns} 25 | */ 26 | export function useDisableEvent(disabled?: boolean): UseDisableEventReturns { 27 | return useCallback( 28 | /** 29 | * @param {SyntheticEvent} event 30 | * @returns {void} 31 | */ 32 | (event: React.SyntheticEvent) => { 33 | // Returns early if the event has been prevented previously 34 | if (event.defaultPrevented) { 35 | return; 36 | } 37 | 38 | // If an element is disabled, then stops the event bubbling and prevents its 39 | // default behaviour 40 | if (disabled) { 41 | event.stopPropagation(); 42 | event.preventDefault(); 43 | } 44 | }, 45 | [disabled], 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /documentation/src/components/props-table/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useDynamicImport } from "./useDynamicImport"; 3 | import styles from "./styles.module.scss"; 4 | 5 | export const PropsTable = ({ name }: { name: string }) => { 6 | const { props } = useDynamicImport(name); 7 | 8 | if (!props || Object.keys(props).length <= 0) { 9 | return null; 10 | } 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {Object.keys(props).map((key) => { 24 | return ( 25 | 26 | 29 | 32 | 39 | 40 | 41 | 42 | ); 43 | })} 44 | 45 |
NameTypeDefault ValueRequiredDescription
27 | {key} 28 | 30 | {props[key].type?.name} 31 | 33 | {props[key].defaultValue ? ( 34 | {props[key].defaultValue.value} 35 | ) : ( 36 | - 37 | )} 38 | {props[key].required ? "Yes" : "No"}{props[key].description}
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /documentation/src/components/props-table/useDynamicImport.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import type { Props } from "react-docgen-typescript"; 3 | import { useSafeLayoutEffect } from "@feedzai/js-utilities/hooks"; 4 | 5 | type DocgenInfo = { 6 | props?: Props; 7 | description?: string; 8 | }; 9 | 10 | export const useDynamicImport = (name: string): DocgenInfo => { 11 | const [info, setInfo] = useState({}); 12 | 13 | useSafeLayoutEffect(() => { 14 | let resolved = false; 15 | 16 | try { 17 | import(`../../../.docusaurus/globalData.json`) 18 | .then((importedInfo) => { 19 | if (!resolved) { 20 | resolved = true; 21 | const data: any[] = 22 | importedInfo.default["docusaurus-plugin-react-docgen-typescript"].default; 23 | const info = data 24 | .filter((item) => item.displayName === name) 25 | .map((item) => { 26 | return { 27 | props: item.props, 28 | description: item.description, 29 | }; 30 | }); 31 | 32 | setInfo(info[0]); 33 | } 34 | }) 35 | .catch(() => console.error(`Not found DocgenInfo for ${name}.`)); 36 | } catch (e) { 37 | console.error(e); 38 | } 39 | 40 | return () => { 41 | resolved = true; 42 | }; 43 | }, [name]); 44 | 45 | return info; 46 | }; 47 | -------------------------------------------------------------------------------- /documentation/src/components/preview/styles.module.scss: -------------------------------------------------------------------------------- 1 | .browser { 2 | border: 3px solid var(--ifm-color-emphasis-200); 3 | border-top-left-radius: var(--ifm-global-radius); 4 | border-top-right-radius: var(--ifm-global-radius); 5 | box-shadow: var(--ifm-global-shadow-lw); 6 | 7 | &__header { 8 | align-items: center; 9 | background: var(--ifm-color-emphasis-200); 10 | display: flex; 11 | padding: 0.5rem 1rem; 12 | 13 | * { 14 | user-select: none; 15 | } 16 | } 17 | 18 | &__address-bar { 19 | flex: 1 0; 20 | margin: 0 1rem 0 0.5rem; 21 | padding: 5px 15px; 22 | font: 400 13px Arial; 23 | user-select: none; 24 | } 25 | 26 | &__menu-icon { 27 | margin-left: auto; 28 | } 29 | 30 | &__body { 31 | position: relative; 32 | padding: 1rem; 33 | } 34 | } 35 | 36 | .row:after { 37 | content: ""; 38 | display: table; 39 | clear: both; 40 | } 41 | 42 | .buttons { 43 | white-space: nowrap; 44 | } 45 | 46 | .right { 47 | align-self: center; 48 | width: 10%; 49 | } 50 | 51 | .dot { 52 | margin-right: 6px; 53 | margin-top: 4px; 54 | height: 12px; 55 | width: 12px; 56 | background-color: #bbb; 57 | border-radius: 50%; 58 | display: inline-block; 59 | } 60 | 61 | .bar { 62 | width: 17px; 63 | height: 3px; 64 | background-color: #aaa; 65 | margin: 3px 0; 66 | display: block; 67 | } 68 | -------------------------------------------------------------------------------- /cypress/test/hooks/use-focus.spec.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useRef, useState } from "react"; 2 | import { useFocus } from "../../../src"; 3 | 4 | function Demo({ isFocused = false, onFocus }: { isFocused?: boolean; onFocus: () => void }) { 5 | const [focused, setFocused] = useState(isFocused); 6 | 7 | const buttonRef = useRef(null); 8 | 9 | useFocus(buttonRef, focused); 10 | 11 | const handleOnClick = useCallback(() => { 12 | setFocused(true); 13 | }, [setFocused]); 14 | 15 | return ( 16 | 19 | ); 20 | } 21 | 22 | it("does not focus on mount when false", () => { 23 | cy.mount(); 24 | 25 | cy.get("@onFocus").should("not.have.been.called"); 26 | }); 27 | 28 | it("focuses on mount when true", () => { 29 | cy.mount(); 30 | 31 | cy.get("@onFocus").should("have.been.called"); 32 | }); 33 | 34 | it("focuses when focus value changes to true", () => { 35 | cy.mount(); 36 | 37 | cy.get("@onFocus").should("not.have.been.called"); 38 | 39 | cy.get("button").click(); 40 | 41 | cy.get("@onFocus").should("have.been.called"); 42 | }); 43 | -------------------------------------------------------------------------------- /src/components/roving-tabindex/rover-provider/provider.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | 8 | /** 9 | * provider.tsx 10 | * 11 | * @author João Dias 12 | * @since 1.0.0 13 | */ 14 | import React, { ReactElement, useReducer, useEffect } from "react"; 15 | import { IRoverProviderProps } from "../index"; 16 | import { initialState, RoverContext } from "./context"; 17 | import { reducer } from "./reducer"; 18 | 19 | /** 20 | * Rover Context Provider 21 | * 22 | * @example 23 | * 24 | * 25 | * 26 | * 27 | * 28 | * @param {IRoverProviderProps} { children, direction = "vertical" } 29 | * @returns {ReactElement} 30 | */ 31 | export function Provider({ children, direction = "vertical" }: IRoverProviderProps): ReactElement { 32 | const [state, dispatch] = useReducer(reducer, initialState); 33 | 34 | useEffect(() => { 35 | dispatch({ 36 | type: "CHANGE_DIRECTION", 37 | payload: { direction }, 38 | }); 39 | }, [direction, dispatch]); 40 | 41 | const value = { 42 | state, 43 | dispatch, 44 | }; 45 | 46 | return {children}; 47 | } 48 | -------------------------------------------------------------------------------- /cypress/support/a11y/injectAxe.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * The copyright of this file belongs to Feedzai. The file cannot be 3 | * reproduced in whole or in part, stored in a retrieval system, transmitted 4 | * in any form, or by any means electronic, mechanical, or otherwise, without 5 | * the prior permission of the owner. Please refer to the terms of the license 6 | * agreement. 7 | * 8 | * (c) 2022 Feedzai, Rights Reserved. 9 | */ 10 | 11 | /** 12 | * injectAxe.ts 13 | * 14 | * @author João Dias 15 | * @since ```feedzai.next.release``` 16 | */ 17 | 18 | /** 19 | * This will inject the `axe-core` runtime into the page under test. You must run this after a call to ` cy.visit()` and before you run the `checkA11y` command. 20 | * You can run this command with `cy.injectAxe()` either in your test, or in a `beforeEach`, as long as the visit comes first 21 | * 22 | * @example 23 | * 24 | * beforeEach(() => { 25 | * cy.visit('http://localhost:9000'); 26 | * cy.injectAxe(); 27 | * }) 28 | */ 29 | const injectAxe = () => { 30 | let fileName; 31 | try { 32 | fileName = require.resolve('axe-core/axe.min.js'); 33 | } catch { 34 | fileName = 'node_modules/axe-core/axe.min.js'; 35 | } 36 | cy.readFile(fileName).then((source) => 37 | cy.window({ log: false }).then((window) => { 38 | window.eval(source); 39 | }) 40 | ); 41 | }; 42 | 43 | export default injectAxe; 44 | -------------------------------------------------------------------------------- /src/components/skip-links/index.module.scss: -------------------------------------------------------------------------------- 1 | :global(.fdz-css-skip-links__item), 2 | .item { 3 | --skip-link-background: #111; 4 | --skip-link-color: #fff; 5 | --skip-link-font-family: sans-serif; 6 | --skip-link-font-size: 0.875rem; 7 | --skip-link-left: 2rem; 8 | --skip-link-line-height: 2rem; 9 | --skip-link-top: 1rem; 10 | --skip-link-opacity: 1; 11 | --skip-link-outline-color: var(--skip-link-background); 12 | 13 | position: absolute; 14 | top: var(--skip-link-top); 15 | left: var(--skip-link-left); 16 | right: auto; 17 | font-size: var(--skip-link-font-size); 18 | font-family: var(--skip-link-font-family); 19 | background: var(--skip-link-background); 20 | color: var(--skip-link-color); 21 | display: flex; 22 | justify-content: center; 23 | align-items: center; 24 | letter-spacing: 0.5px; 25 | line-height: var(--skip-link-line-height); 26 | padding: 0 1rem; 27 | appearance: none; 28 | text-align: center; 29 | text-decoration: none; 30 | height: 2rem; 31 | min-width: 10rem; 32 | z-index: 100; 33 | outline-width: 2px; 34 | outline-style: solid; 35 | outline-color: var(--skip-link-outline-color); 36 | outline-offset: 2px; 37 | } 38 | 39 | :global(.fdz-css-skip-links__item):not(:focus), 40 | .item:not(:focus) { 41 | border: 0; 42 | clip: rect(0 0 0 0); 43 | height: auto; 44 | margin: 0; 45 | overflow: hidden; 46 | opacity: var(--skip-link-opacity); 47 | padding: 0; 48 | position: absolute !important; 49 | width: 1px; 50 | white-space: nowrap; 51 | } 52 | -------------------------------------------------------------------------------- /cypress/test/components/roving-tabindex/use-focus-effect.spec.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | import { useCallback, useRef, useState } from "react"; 8 | import { useFocus } from "../../../../src/components/roving-tabindex/use-focus-effect"; 9 | 10 | function Demo({ isFocused = false, onFocus }: { isFocused?: boolean; onFocus: () => void }) { 11 | const [focused, setFocused] = useState(isFocused); 12 | 13 | const buttonRef = useRef(null); 14 | 15 | useFocus(buttonRef, focused); 16 | 17 | const handleOnClick = useCallback(() => { 18 | setFocused(true); 19 | }, [setFocused]); 20 | 21 | return ( 22 | 25 | ); 26 | } 27 | 28 | it("does not focus on mount when false", () => { 29 | cy.mount(); 30 | 31 | cy.get("@onFocus").should("not.have.been.called"); 32 | }); 33 | 34 | it("focuses on mount when true", () => { 35 | cy.mount(); 36 | 37 | cy.get("@onFocus").should("have.been.called"); 38 | }); 39 | 40 | it("focuses when focus value changes to true", () => { 41 | cy.mount(); 42 | 43 | cy.get("@onFocus").should("not.have.been.called"); 44 | 45 | cy.get("button").click(); 46 | 47 | cy.get("@onFocus").should("have.been.called"); 48 | }); 49 | -------------------------------------------------------------------------------- /documentation/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-a11y-tools-docs", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "^3.4.0", 19 | "@docusaurus/preset-classic": "^3.4.0", 20 | "@mdx-js/react": "^3.0.0", 21 | "clsx": "^2.0.0", 22 | "docusaurus-plugin-sass": "^0.2.5", 23 | "prism-react-renderer": "^2.3.0", 24 | "react": "^18.0.0", 25 | "react-dom": "^18.0.0", 26 | "sass": "^1.71.1" 27 | }, 28 | "devDependencies": { 29 | "@docusaurus/module-type-aliases": "^3.4.0", 30 | "@docusaurus/tsconfig": "^3.4.0", 31 | "@docusaurus/types": "3.1.1", 32 | "docusaurus-plugin-react-docgen-typescript": "^1.1.0", 33 | "react-docgen-typescript": "^2.2.2", 34 | "typescript": "~5.2.2" 35 | }, 36 | "browserslist": { 37 | "production": [ 38 | "last 2 chrome version", 39 | "last 2 firefox version", 40 | "last 2 safari version" 41 | ], 42 | "development": [ 43 | "last 2 chrome version", 44 | "last 2 firefox version", 45 | "last 2 safari version" 46 | ] 47 | }, 48 | "engines": { 49 | "node": ">=18.0", 50 | "npm": ">= 10.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/components/announcer/messages/consumer.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | 8 | /** 9 | * consumer.tsx 10 | * 11 | * @author João Dias 12 | * @since 1.0.0 13 | */ 14 | import React from "react"; 15 | import { AnnouncementContext } from "."; 16 | import { MessagesAnnouncerContext } from "./context"; 17 | 18 | /** 19 | * Messages Announcer Context Consumer 20 | * 21 | * A Context API wrapper suitable for class-based components, where `useMessagesAnnouncer` hook is not possible. 22 | * 23 | * @example 24 | * 25 | * {(setMessage) => { 26 | * return ( 27 | * 33 | * ); 34 | * }} 35 | * 36 | * 37 | * @export 38 | * @param {({ 39 | * children: (message: string, politeness: "assertive" | "polite", setMessage: (message: ISetMessage) => void) => React.ReactElement; 40 | * })} props 41 | * @returns 42 | */ 43 | export function MessagesAnnouncerConsumer({ 44 | children, 45 | }: { 46 | children: (setMessage: AnnouncementContext["setMessage"]) => React.ReactElement; 47 | }) { 48 | return ( 49 | 50 | {({ setMessage }) => children(setMessage)} 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:@typescript-eslint/recommended", "plugin:cypress/recommended", "prettier"], 3 | "env": { 4 | "browser": true, 5 | "es6": true, 6 | "node": true 7 | }, 8 | "parser": "@typescript-eslint/parser", 9 | "plugins": ["react", "react-hooks", "@typescript-eslint", "prettier"], 10 | "rules": { 11 | "react/no-did-mount-set-state": "warn", 12 | "react/no-did-update-set-state": "warn", 13 | "consistent-return": "warn", 14 | "complexity": "warn", 15 | "jest/no-disabled-tests": "off", 16 | "react/no-access-state-in-setstate": "warn", 17 | "no-prototype-builtins": "warn", 18 | "jsx-a11y/no-autofocus": 0, 19 | "spaced-comment": ["error", "always", { "markers": ["/"] }], 20 | "@typescript-eslint/no-unused-vars": "off", 21 | "@typescript-eslint/no-explicit-any": "off", 22 | "no-use-before-define": "off", 23 | "camelcase": "off" 24 | }, 25 | "settings": { 26 | "import/parsers": { 27 | "@typescript-eslint/parser": [".ts", ".tsx"] 28 | }, 29 | "import/resolver": { 30 | "typescript": { 31 | "project": "./" 32 | }, 33 | "node": { 34 | "extensions": [".js", ".jsx", ".ts", ".tsx"], 35 | "moduleDirectory": ["node_modules", "src/"] 36 | } 37 | }, 38 | "react": { 39 | "version": "detect" 40 | } 41 | }, 42 | "overrides": [ 43 | { 44 | "files": ["**/*.tsx", "**/*.ts"], 45 | "rules": { 46 | "react/prop-types": "off", 47 | "no-undef": "off", 48 | "react/display-name": "off", 49 | "@typescript-eslint/explicit-module-boundary-types": "off" 50 | } 51 | } 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /documentation/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --ifm-color-primary: rgb(105, 183, 69); 10 | --ifm-color-primary-dark: rgb(33, 175, 144); 11 | --ifm-color-primary-darker: rgb(31, 165, 136); 12 | --ifm-color-primary-darkest: rgb(26, 136, 112); 13 | --ifm-color-primary-light: rgb(70, 203, 174); 14 | --ifm-color-primary-lighter: rgb(102, 212, 189); 15 | --ifm-color-primary-lightest: rgb(146, 224, 208); 16 | --ifm-code-font-size: 95%; 17 | } 18 | 19 | .docusaurus-highlight-code-line { 20 | background-color: rgba(0, 0, 0, 0.1); 21 | display: block; 22 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 23 | padding: 0 var(--ifm-pre-padding); 24 | } 25 | 26 | html[data-theme="dark"] .docusaurus-highlight-code-line { 27 | background-color: rgba(0, 0, 0, 0.3); 28 | } 29 | 30 | a:hover, 31 | a:focus, 32 | .menu__link--active { 33 | --ifm-link-hover-decoration: underline; 34 | } 35 | 36 | .menu__list .menu__list-item .menu__list .menu__link.menu__link--active { 37 | box-shadow: inset -4px 0 0 0 var(--ifm-heading-color, white); 38 | border-top-right-radius: 0; 39 | border-bottom-right-radius: 0; 40 | } 41 | 42 | .sr-only, 43 | .visually-hidden { 44 | border: 0; 45 | clip: rect(0 0 0 0); 46 | height: auto; 47 | margin: 0; 48 | overflow: hidden; 49 | padding: 0; 50 | position: absolute !important; 51 | width: 1px; 52 | white-space: nowrap; 53 | } 54 | -------------------------------------------------------------------------------- /cypress/e2e/skip-links.cy.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /* 4 | * Please refer to the terms of the license 5 | * agreement. 6 | * 7 | * (c) 2021 Feedzai, Rights Reserved. 8 | */ 9 | 10 | /** 11 | * skip-links.spec.js 12 | * 13 | * @author João Dias 14 | * @since 1.0.0 15 | */ 16 | const ROVER_STORY_URL = "/docs/content-and-navigation/skip-links"; 17 | 18 | describe("Skip Links", () => { 19 | beforeEach(() => { 20 | cy.visit(ROVER_STORY_URL); 21 | 22 | cy.findByTestId("fdz-js-docs-browser-window").as("preview"); 23 | 24 | cy.get("@preview").within(() => { 25 | cy.findByRole("link", { name: "Skip to main content" }).as("SkipMainContent"); 26 | cy.findByRole("link", { name: "Go to navigation menu" }).as("SkipNavigationMenu"); 27 | cy.findByTestId("fdz-js-skip-links-main").as("MainContent"); 28 | cy.findByTestId("fdz-js-skip-links-nav").as("NavigationMenu"); 29 | }); 30 | }); 31 | 32 | it("should not show any skip link by default", () => { 33 | cy.get("@SkipMainContent").should("not.have.focus"); 34 | cy.get("@SkipNavigationMenu").should("not.have.focus"); 35 | }); 36 | 37 | it("should show skip links on pressing the Tab key", () => { 38 | cy.get("@preview").within(() => { 39 | cy.findByTestId("fdz-js-skip-links-target-button").click().realPress("Tab"); 40 | cy.get("@SkipMainContent").should("have.focus"); 41 | 42 | cy.focused().realPress("Tab"); 43 | cy.get("@SkipMainContent").should("not.have.focus"); 44 | cy.get("@SkipNavigationMenu").should("have.focus"); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/components/skip-links/index.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | 8 | /** 9 | * index.tsx 10 | * 11 | * @author João Dias 12 | * @since 1.0.0 13 | */ 14 | import React, { FunctionComponent } from "react"; 15 | import { ISkipLink, SkipLink, SKIP_LINK_DEFAULT_PROPS } from "./link"; 16 | export interface ISkipLinksProps { 17 | items?: ISkipLink[]; 18 | } 19 | 20 | export const SKIP_LINK_ITEMS_DEFAULT_PROPS = { 21 | items: [ 22 | { 23 | ...SKIP_LINK_DEFAULT_PROPS, 24 | }, 25 | ], 26 | }; 27 | 28 | /** 29 | * Skip Links are links at the top of the page which jumps the user down to an anchor 30 | * or target at the beginning of the main content 31 | * 32 | * @param {ISkipLinksProps} props 33 | * @returns {JSX.Element} 34 | */ 35 | export const SkipLinks: FunctionComponent = ({ items }) => { 36 | /** 37 | * Renders a list of `SkipLink` components 38 | * 39 | * @returns {JSX.Element} 40 | */ 41 | function renderLinks() { 42 | const list = 43 | items && items.length > 0 ? ( 44 | items.map((item) => ) 45 | ) : ( 46 | 51 | ); 52 | 53 | return ( 54 |
55 | {list} 56 |
57 | ); 58 | } 59 | 60 | return renderLinks(); 61 | }; 62 | 63 | SkipLinks.defaultProps = SKIP_LINK_ITEMS_DEFAULT_PROPS; 64 | -------------------------------------------------------------------------------- /src/components/focus-manager/helpers/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2024 Feedzai, Rights Reserved. 6 | */ 7 | import { FocusManagerProps } from "../types"; 8 | import { Options } from "focus-trap"; 9 | 10 | /** 11 | * Sets the initial focus element. 12 | */ 13 | export function getInitialFocus( 14 | autoFocus: FocusManagerProps["autoFocus"], 15 | initialFocus: Options["initialFocus"], 16 | ) { 17 | // If true, resumes the normal behaviour 18 | if (autoFocus === true) { 19 | return undefined; 20 | } 21 | 22 | // If false, prevents the autoFocus from happening 23 | else if (autoFocus === false) { 24 | return false; 25 | } 26 | 27 | // If is nil, uses the value defined in the `initialFocus` param, 28 | // which if also nil, resumes the normal behaviour 29 | return initialFocus; 30 | } 31 | 32 | /** 33 | * Sets the fallback focus element to a: 34 | * 1. Defined option as prop 35 | * 2. A fallback ref element 36 | */ 37 | export function getFallbackFocus( 38 | element: GenericElement | null, 39 | fallbackFocus: Options["fallbackFocus"], 40 | ) { 41 | if (fallbackFocus) { 42 | return fallbackFocus; 43 | } 44 | 45 | return element ?? undefined; 46 | } 47 | 48 | /** 49 | * Returns the correct prop value for the return focus functionality. 50 | */ 51 | export function getReturnFocusProps(restoreFocus: FocusManagerProps["restoreFocus"]) { 52 | if (restoreFocus && restoreFocus === true) { 53 | return { 54 | returnFocusOnDeactivate: true, 55 | }; 56 | } 57 | 58 | return { 59 | setReturnFocus: restoreFocus, 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /cypress/support/a11y/configureAxe.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * The copyright of this file belongs to Feedzai. The file cannot be 3 | * reproduced in whole or in part, stored in a retrieval system, transmitted 4 | * in any form, or by any means electronic, mechanical, or otherwise, without 5 | * the prior permission of the owner. Please refer to the terms of the license 6 | * agreement. 7 | * 8 | * (c) 2022 Feedzai, Rights Reserved. 9 | */ 10 | 11 | /** 12 | * configureAxe.ts 13 | * 14 | * @author João Dias 15 | * @since ```feedzai.next.release``` 16 | */ 17 | 18 | /** 19 | * Its purpose is to configure the format of the data used by aXe. 20 | * This can be used to add new rules, which must be registered with the library to execute. 21 | * 22 | * User specifies the format of the JSON structure passed to the callback of axe.run 23 | * 24 | * {@link https://www.deque.com/axe/documentation/api-documentation/#api-name-axeconfigure|aXe Docs: axe.configure} 25 | * 26 | * @example 27 | * it('Has no detectable a11y violations on load (custom configuration)', () => { 28 | * // Configure aXe and test the page at initial load 29 | * cy.configureAxe({ 30 | * branding: { 31 | * brand: String, 32 | * application: String 33 | * }, 34 | * reporter: 'option', 35 | * checks: [Object], 36 | * rules: [Object], 37 | * locale: Object 38 | * }); 39 | * cy.checkA11y(); 40 | * }) 41 | */ 42 | const configureAxe = (configurationOptions = {}) => { 43 | cy.window({ log: false }).then((win) => { 44 | return win.axe.configure(configurationOptions); 45 | }); 46 | }; 47 | 48 | export default configureAxe; 49 | -------------------------------------------------------------------------------- /documentation/docs/hooks/demos/useTabbable/index.modules.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | align-items: center; 6 | gap: 2rem; 7 | } 8 | 9 | .row { 10 | display: flex; 11 | flex-direction: row; 12 | justify-content: center; 13 | align-items: center; 14 | gap: 2rem; 15 | } 16 | 17 | .button { 18 | appearance: none; 19 | padding: 0 1rem; 20 | background: hsl(265, 100%, 47%); 21 | border: none; 22 | color: hsl(262, 21%, 93%); 23 | cursor: pointer; 24 | 25 | &:hover, 26 | &:focus { 27 | color: hsl(262, 21%, 100%); 28 | background: hsl(265, 100%, 39%); 29 | } 30 | 31 | &:focus { 32 | outline: 1px solid hsl(262, 21%, 93%); 33 | outline-offset: 4px; 34 | } 35 | 36 | &:disabled, 37 | &[aria-disabled="true"] { 38 | color: hsl(264, 100%, 97%); 39 | background: hsl(265, 15%, 47%); 40 | cursor: not-allowed; 41 | } 42 | } 43 | 44 | .fieldset { 45 | border: none; 46 | display: flex; 47 | flex-direction: column; 48 | justify-content: flex-end; 49 | align-items: flex-start; 50 | gap: 0.5rem; 51 | padding: 0; 52 | } 53 | 54 | .label { 55 | font-size: 0.875rem; 56 | } 57 | 58 | .button, 59 | .input { 60 | height: 3rem; 61 | font-size: 1rem; 62 | 63 | &:focus { 64 | outline: 1px solid hsl(262, 21%, 93%); 65 | outline-offset: 4px; 66 | } 67 | } 68 | 69 | .input { 70 | padding: 0.5rem 1rem; 71 | color: hsl(264, 100%, 97%); 72 | background: hsl(267, 13%, 17%); 73 | border: currentColor; 74 | 75 | &:disabled, 76 | &[aria-disabled="true"] { 77 | color: hsl(264, 100%, 97%); 78 | background: hsl(267, 7%, 30%); 79 | cursor: not-allowed; 80 | user-select: none; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /cypress/support/a11y/assertions/isAriaDisabled.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /* 3 | * The copyright of this file belongs to Feedzai. The file cannot be 4 | * reproduced in whole or in part, stored in a retrieval system, transmitted 5 | * in any form, or by any means electronic, mechanical, or otherwise, without 6 | * the prior permission of the owner. Please refer to the terms of the license 7 | * agreement. 8 | * 9 | * (c) 2022 Feedzai, Rights Reserved. 10 | */ 11 | 12 | /** 13 | * isAriaDisabled.ts 14 | * 15 | * @author João Dias 16 | * @since ```feedzai.next.release``` 17 | */ 18 | 19 | 20 | /** 21 | * Chai assert that verifies if an element has/hasn't got the `aria-disabled` 22 | * attribute and that it's value is set to "true" 23 | * 24 | * @see https://www.chaijs.com/guide/helpers/ 25 | * 26 | * @example 27 | * // Assert on a test file: 28 | * ``` 29 | * expect(element).to.be.aria-disabled() 30 | * expect(element).to.not.be.aria-disabled() 31 | * cy.wrap('foo').should('be.aria-disabled') 32 | * cy.wrap('foo').should('not.be.aria-disabled') 33 | * ``` 34 | **/ 35 | export const isAriaDisabled = (config: Chai.ChaiStatic, utils: Chai.ChaiUtils): void => { 36 | /** 37 | * @param {any} options 38 | * @returns {void} 39 | */ 40 | function assertIsAriaDisabled(options) { 41 | this.assert( 42 | this._obj.attr("aria-disabled") === "true", 43 | `expected #{this} to be disabled with "aria-disabled='true'"`, 44 | `expected #{this} not to be disabled with "aria-disabled='true'"`, 45 | this._obj, 46 | ); 47 | } 48 | 49 | config.Assertion.addMethod("aria-disabled", assertIsAriaDisabled); 50 | }; 51 | -------------------------------------------------------------------------------- /src/typings/polymorphic.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | import React from "react"; 3 | 4 | // Source: https://github.com/emotion-js/emotion/blob/master/packages/styled-base/types/helper.d.ts 5 | // A more precise version of just React.ComponentPropsWithoutRef on its own 6 | export type PropsOf> = 7 | JSX.LibraryManagedAttributes>; 8 | 9 | type AsProp = { 10 | /** 11 | * An override of the default HTML tag. 12 | * Can also be another React component. 13 | */ 14 | as?: C; 15 | }; 16 | 17 | /** 18 | * Allows for extending a set of props (`ExtendedProps`) by an overriding set of props 19 | * (`OverrideProps`), ensuring that any duplicates are overridden by the overriding 20 | * set of props. 21 | */ 22 | export type ExtendableProps = OverrideProps & 23 | Omit; 24 | 25 | /** 26 | * Allows for inheriting the props from the specified element type so that 27 | * props like children, className & style work, as well as element-specific 28 | * attributes like aria roles. The component (`C`) must be passed in. 29 | */ 30 | export type InheritableElementProps = ExtendableProps< 31 | PropsOf, 32 | Props 33 | >; 34 | 35 | /** 36 | * A more sophisticated version of `InheritableElementProps` where 37 | * the passed in `as` prop will determine which props can be included 38 | */ 39 | export type PolymorphicComponentProps< 40 | C extends React.ElementType, 41 | Props = {}, 42 | > = InheritableElementProps>; 43 | -------------------------------------------------------------------------------- /src/components/roving-tabindex/use-focus-effect.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | 8 | /** 9 | * use-focus-effect.ts 10 | * 11 | * @author João Dias 12 | * @since 1.0.0 13 | */ 14 | import { useEffect, RefObject } from "react"; 15 | import { focusWithoutScrolling } from "../../helpers"; 16 | 17 | /** 18 | * Invokes `focus()` on a DOM element. 19 | * Supports for a `ref` as well as an `HTMLElement` or `SVGElement` 20 | * 21 | * @example 22 | * 23 | * // Place the focus on an element with a ref 24 | * useFocus(buttonRef, true); 25 | * 26 | * // or 27 | * useFocus(buttonRef); 28 | * 29 | * // Place the focus on an element queried from the DOM 30 | * useFocus(document.querySelector("button"), true); 31 | * 32 | * // Place the focus on an element with a ref, but prevent scrolling when calling the `focus()` method 33 | * useFocus(buttonRef, true, false); 34 | * 35 | * @export 36 | * @template GenericElement 37 | * @param {RefObject} element 38 | * @param {(boolean)} [willFocus=true] 39 | * @param {(boolean)} [scrollWhenFocus=true] 40 | * @returns {void} 41 | */ 42 | export function useFocus( 43 | element: RefObject | GenericElement, 44 | willFocus: boolean | undefined = true, 45 | scrollWhenFocus: boolean | undefined = true, 46 | ): void { 47 | useEffect(() => { 48 | if (willFocus) { 49 | const DOMElement = "current" in element ? element.current : element; 50 | 51 | if (DOMElement) { 52 | scrollWhenFocus ? DOMElement.focus() : focusWithoutScrolling(DOMElement); 53 | } 54 | } 55 | }, [element, willFocus]); 56 | } 57 | -------------------------------------------------------------------------------- /src/components/skip-links/link.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | 8 | /** 9 | * link.tsx 10 | * 11 | * @author João Dias 12 | * @since 1.0.0 13 | */ 14 | import React, { FunctionComponent, useCallback, KeyboardEvent } from "react"; 15 | import { classNames } from "@feedzai/js-utilities"; 16 | import { useConstant } from "@feedzai/js-utilities/hooks"; 17 | import styles from "./index.module.scss"; 18 | 19 | export const SKIP_LINK_DEFAULT_PROPS: ISkipLink = { 20 | target: "#content", 21 | text: "Skip to main content", 22 | as: "link", 23 | }; 24 | 25 | export interface ISkipLink { 26 | target: string; 27 | text: string; 28 | as?: "link" | "button"; 29 | } 30 | 31 | /** 32 | * Skip Link to Main Content 33 | * 34 | * @param {ISkipLink} props 35 | * @returns {JSX.Element} 36 | */ 37 | export const SkipLink: FunctionComponent = ({ target, text, as }) => { 38 | const CSS_CLASS = useConstant(() => { 39 | return classNames(styles.item, "fdz-css-skip-links__item"); 40 | }); 41 | 42 | const onKeyUp = useCallback( 43 | (event: KeyboardEvent) => { 44 | if (event.key === "Enter" || event.key === " ") { 45 | const targetElement: HTMLElement | null = document.querySelector(target); 46 | 47 | targetElement?.focus(); 48 | } 49 | }, 50 | [target], 51 | ); 52 | 53 | if (as === "button") { 54 | return ( 55 | 64 | ); 65 | } 66 | return ( 67 | 68 | {text} 69 | 70 | ); 71 | }; 72 | 73 | SkipLink.defaultProps = SKIP_LINK_DEFAULT_PROPS; 74 | 75 | export default SkipLink; 76 | -------------------------------------------------------------------------------- /documentation/docs/testing/keyboard-only.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | title: Keyboard Only 4 | --- 5 | 6 | import { DemoKeyboardOnly } from "./demos"; 7 | import { Subtitle, BrowserWindow, PropsTable } from "../../src/components/index"; 8 | 9 | Hide your mouse pointer and test your interfaces only with your keyboard 10 | 11 | 12 | 13 | 14 |
15 | 16 | :::note How to 17 | 18 | - Click on the `Disable Mouse` button 19 | - Check if this demo is accessible. (Spoiler alert: It's not 😀) 20 | - Can you press `tab` and navigate through the interface? 21 | - Can you navigate to landmark areas? 22 | ::: 23 | 24 | ### Rationale 25 | 26 | Suppose you - or a colleague of yours - is working on a component and with accessibility in mind (as you should). 27 | Then, hiding the mouse pointer might be an excellent way to test out possible regressions. 28 | 29 | By not showing any mouse pointer on the screen, this little helper will "force" you to leave your pointing devices aside and think about all the possible ways your component needs to work like: 30 | 31 | - "Does this component/page work the same with the keyboard and a mouse"? 32 | - "Can I do the same things or, at least, achieve the same outcome"? 33 | - "Is the focus is visible"? 34 | - "Is it perceivable and operable with the keyboard"? 35 | 36 | It might also point you towards learning how to operate an interface using a screen-reader, like VoiceOver, NVDA, Narrator or JAWS. 37 | 38 | Of course, a component being operable with the keyboard is not the same as being accessible, but it's a good starting point. 39 | 40 | :::info 💡 Tip 41 | 42 | For instance, while developing, you can wrap all your content with the `KeyboardOnly` component, or place it as hight as you can on your tree. 43 | This helps you test if your content works as expected. 44 | 45 | Of course, before committing or releasing to production, remove the component from the DOM. 46 | ::: 47 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-interface */ 2 | /* 3 | * Please refer to the terms of the license 4 | * agreement. 5 | * 6 | * (c) 2021 Feedzai, Rights Reserved. 7 | */ 8 | 9 | /** 10 | * modules.d.ts 11 | * 12 | * @author João Dias 13 | * @since 1.0.0 14 | */ 15 | declare module "*.css"; 16 | declare module "*.module.scss"; 17 | 18 | declare interface Navigator extends NavigatorUA {} 19 | declare interface WorkerNavigator extends NavigatorUA {} 20 | 21 | // https://wicg.github.io/ua-client-hints/#navigatorua 22 | declare interface NavigatorUA { 23 | readonly userAgentData?: NavigatorUAData; 24 | } 25 | 26 | // https://wicg.github.io/ua-client-hints/#dictdef-navigatoruabrandversion 27 | interface NavigatorUABrandVersion { 28 | readonly brand: string; 29 | readonly version: string; 30 | } 31 | 32 | // https://wicg.github.io/ua-client-hints/#dictdef-uadatavalues 33 | interface UADataValues { 34 | readonly brands?: NavigatorUABrandVersion[]; 35 | readonly mobile?: boolean; 36 | readonly platform?: string; 37 | readonly architecture?: string; 38 | readonly bitness?: string; 39 | readonly model?: string; 40 | readonly platformVersion?: string; 41 | /** @deprecated in favour of fullVersionList */ 42 | readonly uaFullVersion?: string; 43 | readonly fullVersionList?: NavigatorUABrandVersion[]; 44 | readonly wow64?: boolean; 45 | } 46 | 47 | // https://wicg.github.io/ua-client-hints/#dictdef-ualowentropyjson 48 | interface UALowEntropyJSON { 49 | readonly brands: NavigatorUABrandVersion[]; 50 | readonly mobile: boolean; 51 | readonly platform: string; 52 | } 53 | 54 | // https://wicg.github.io/ua-client-hints/#navigatoruadata 55 | interface NavigatorUAData extends UALowEntropyJSON { 56 | getHighEntropyValues(hints: string[]): Promise; 57 | toJSON(): UALowEntropyJSON; 58 | } 59 | 60 | interface Window { 61 | __react_a11y_tools_activeScope__: RefObject | null; 62 | __react_a11y_tools_scopes__: Set>; 63 | } 64 | -------------------------------------------------------------------------------- /cypress/test/components/semantic-heading/index.spec.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /* 4 | * Please refer to the terms of the license agreement. 5 | * 6 | * (c) 2021 Feedzai, Rights Reserved. 7 | */ 8 | 9 | /** 10 | * index.tsx 11 | * 12 | * @author João Dias 13 | * @since 1.0.0 14 | */ 15 | 16 | import React from "react"; 17 | import { Heading, Level } from "../../../../src/components/semantic-headings"; 18 | import { useHeadings } from "../../../../src/components/semantic-headings/useHeadings"; 19 | 20 | it("Heading renders an H1 be default", () => { 21 | cy.mount(A test title); 22 | cy.findByText("A test title").then(($element) => { 23 | expect($element.prop("tagName")).to.equal("H1"); 24 | }); 25 | }); 26 | 27 | it("Heading respects the offset prop", () => { 28 | cy.mount(A test title); 29 | cy.findByText("A test title").then(($element) => { 30 | expect($element.prop("tagName")).to.equal("H2"); 31 | }); 32 | }); 33 | 34 | it("Level increments the level rendered by Heading", () => { 35 | cy.mount( 36 | 37 | A test title 38 | , 39 | ); 40 | cy.findByText("A test title").then(($element) => { 41 | expect($element.prop("tagName")).to.equal("H2"); 42 | }); 43 | }); 44 | 45 | it("Level allows overriding the level", () => { 46 | cy.mount( 47 | 48 | A test title 49 | , 50 | ); 51 | cy.findByText("A test title").then(($element) => { 52 | expect($element.prop("tagName")).to.equal("H3"); 53 | }); 54 | }); 55 | 56 | describe("in production mode", () => { 57 | it("useHeadings returns the correct heading level", () => { 58 | function Test() { 59 | const level = useHeadings(); 60 | 61 | expect(level).to.equal(3); 62 | 63 | return null; 64 | } 65 | 66 | cy.mount( 67 | 68 | 69 | , 70 | ); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | env: 6 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 7 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 8 | jobs: 9 | quality: 10 | name: Unit, component and e2e tests 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | node-version: 15 | - 18.x 16 | - 20.x 17 | os: 18 | - ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Using node ${{ matrix.node-version }} 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: npm 26 | - run: npm ci 27 | - run: npm run test:ci 28 | docs: 29 | name: Deploy documentation 30 | if: ${{ github.ref == 'refs/heads/main' }} 31 | needs: 32 | - quality 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v3 36 | - uses: actions/setup-node@v3 37 | with: 38 | node-version: "18.x" 39 | cache: npm 40 | - name: Building docs... 41 | run: npm ci 42 | - name: Build website 43 | run: npm run docs:build 44 | - name: Deploying docs... 45 | uses: peaceiris/actions-gh-pages@v3 46 | with: 47 | github_token: ${{ secrets.GITHUB_TOKEN }} 48 | publish_dir: ./documentation/build 49 | user_name: github-actions[bot] 50 | user_email: 41898282+github-actions[bot]@users.noreply.github.com 51 | package_release: 52 | name: Publish to npm 53 | runs-on: ubuntu-latest 54 | if: ${{ github.ref == 'refs/heads/main' }} 55 | needs: 56 | - quality 57 | strategy: 58 | matrix: 59 | node-version: ["20.x"] 60 | steps: 61 | - uses: actions/checkout@v3 62 | - name: Using node ${{ matrix.node-version }} 63 | uses: actions/setup-node@v3 64 | with: 65 | node-version: ${{ matrix.node-version }} 66 | cache: npm 67 | - run: npm ci 68 | - run: npm run build 69 | - run: npm run semantic-release 70 | -------------------------------------------------------------------------------- /cypress/test/components/focus-manager/demos/RestoreFocus.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2024 Feedzai, Rights Reserved. 6 | */ 7 | import React from "react"; 8 | import { useState } from "react"; 9 | import { FocusManager } from "src/components"; 10 | import styles from "./MultipleManagers.module.scss"; 11 | 12 | export function RestoreFocus() { 13 | const [isOpen, setIsOpen] = useState(false); 14 | return ( 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 28 | 29 | 30 | 31 | 32 | 35 | 36 | 37 | 38 | 39 | 42 | 43 | 44 | 45 | 46 | 51 | 52 | 53 | 54 | 55 | 58 | 59 | 60 | 61 | 62 | 65 | 66 |
CompanyContactCountry
Alfreds FutterkisteMaria Anders 26 | 27 |
Centro comercial MoctezumaFrancisco Chang 33 | 34 |
Ernst HandelRoland Mendel 40 | 41 |
Island TradingHelen Bennett 47 | 50 |
Laughing Bacchus WinecellarsYoshi Tannamuri 56 | 57 |
Magazzini Alimentari RiunitiGiovanni Rovelli 63 | 64 |
67 | {isOpen ? ( 68 |
69 | 70 | 73 | 74 |
75 | ) : null} 76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/components/announcer/messages/provider.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | 8 | /** 9 | * provider.tsx 10 | * 11 | * @author João Dias 12 | * @since 1.0.0 13 | */ 14 | import React, { FunctionComponent, useReducer } from "react"; 15 | import { Announcer } from "../announcer"; 16 | import { defaultState, MessagesAnnouncerContext } from "./context"; 17 | import { AnnouncementReducerState } from "./index"; 18 | 19 | function reducer( 20 | state: AnnouncementReducerState, 21 | action: AnnouncementReducerState, 22 | ): AnnouncementReducerState { 23 | switch (action.politeness) { 24 | case "polite": 25 | case "assertive": 26 | return { 27 | politeness: action.politeness, 28 | message: action.message, 29 | }; 30 | 31 | default: 32 | return state; 33 | } 34 | } 35 | 36 | /** 37 | * The `MessagesAnnouncer` is a context-based announcer for the generic types of messages. 38 | * These can be notifications, alerts, form-related submissions, etc. 39 | * 40 | * @example 41 | * // Setting up the announcer at the root-level 42 | * 43 | * 44 | * 45 | * 46 | * // Setting up the announcer hook at the top of a functional component: 47 | * const setMessage = useMessagesAnnouncer(); 48 | * 49 | * // And sending a message 50 | * setMessage({ message: "Form was submitted", politeness: "polite"}); 51 | * 52 | * @param {FunctionComponent} props 53 | * @returns {JSX.Element} 54 | */ 55 | export const MessagesAnnouncer: FunctionComponent<{ children: React.ReactNode }> = ({ 56 | children, 57 | }) => { 58 | const [state, setMessage] = useReducer(reducer, { 59 | message: defaultState.message, 60 | politeness: defaultState.politeness, 61 | }); 62 | 63 | return ( 64 | 70 | {children} 71 | 76 | 77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /documentation/docs/hooks/use-tabbable.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | title: useTabbable 4 | --- 5 | 6 | import { Subtitle, BrowserWindow } from "../../src/components"; 7 | import { DemoUseTabbable } from "./demos"; 8 | 9 | Turning HTML disabled elements perceivable for keyboard users. 10 | 11 | This custom hook indicates that an element is perceivable but disabled, so it is not editable or otherwise operable. 12 | 13 | For example, irrelevant options in a radio group may be disabled. Disabled elements, like buttons, might also not receive focus from the tab order and, for some other disabled elements, applications might choose not to support navigation to descendants. 14 | 15 | So, in order to make an element able to receive focus and be announced by a screen reader, we need to make it "tabbable". 16 | 17 | 18 | 19 | 20 |
21 | 22 | :::note How to 23 | 24 | 1. Press `tab` to focus on the first button ("Enabled"). 25 | 2. Press `tab` again and check that the second button ("Disabled") won't receive focus, but the third ("Disabled, but Tabbable") will. 26 | - VoiceOver output: `"Disabled, but Tabbable, dimmed, button"`. 27 | 3. Press `tab`again and the focus will move to the first input. 28 | 4. Press `tab` again and the disabled input will receive focus 29 | - VoiceOver output: `"36 characters content selected, password, dimmable clickable, secure text"`. 30 | ::: 31 | 32 | ### Usage 33 | 34 | ```jsx 35 | import { useTabbable } from "@feedzai/react-a11y-tools"; 36 | 37 | ... 38 | 39 | const htmlProps = useTabbable({ 40 | ...props, 41 | disabled, 42 | focusable: true, 43 | }); 44 | 45 | (or) 46 | 47 | const htmlProps = useTabbable({ 48 | ...props, 49 | disabled, 50 | focusable, 51 | }); 52 | 53 | ... 54 | 55 | return ( 56 | 57 | ); 58 | ``` 59 | 60 | React will render the `aria-disabled` attribute instead of `disabled`, which means that it will be able to receive focus, but any click, keypress or change events will be supressed. 61 | 62 | ### Extra resources 63 | 64 | - [Making disabled buttons more inclusive](https://css-tricks.com/making-disabled-buttons-more-inclusive/), by [Sandrina Pereira](https://www.sandrina-p.net/). 65 | -------------------------------------------------------------------------------- /cypress/test/components/announcer/messages-announcer.spec.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | 8 | /** 9 | * messages-announcer.spec.tsx 10 | * 11 | * @author João Dias 12 | * @since 1.0.0 13 | */ 14 | import React, { FunctionComponent } from "react"; 15 | import { Announcement } from "../../../../src/components/announcer/messages"; 16 | import { 17 | MessagesAnnouncer, 18 | useMessagesAnnouncer, 19 | } from "../../../../src/components/announcer/messages/index"; 20 | 21 | /** 22 | * Renders a component inside a `MessagesAnnouncer` context provider. 23 | * 24 | * @param {React.ReactElement} ui 25 | */ 26 | function renderWithContext(ui: React.ReactElement) { 27 | cy.mount({ui}); 28 | } 29 | 30 | const App: FunctionComponent = ({ message, politeness, children }): JSX.Element => { 31 | const { setMessage } = useMessagesAnnouncer(); 32 | 33 | function onClick() { 34 | setMessage({ 35 | message, 36 | politeness, 37 | }); 38 | } 39 | return ( 40 |
41 | 44 | {children} 45 |
46 | ); 47 | }; 48 | 49 | describe("", () => { 50 | let props: Announcement; 51 | 52 | beforeEach(() => { 53 | props = { 54 | message: "this is a test message", 55 | politeness: "polite", 56 | }; 57 | }); 58 | 59 | it("should update the announcer with a new message", () => { 60 | renderWithContext(); 61 | 62 | cy.findByText("Send Message").click(); 63 | cy.findByTestId("fdz-js-announcer") 64 | .should("have.text", props.message) 65 | .and("have.attr", "aria-live", props.politeness); 66 | }); 67 | 68 | it("should update the announcer with a new message", () => { 69 | const customProps: Announcement = { 70 | message: "this is a custom message", 71 | politeness: "assertive", 72 | }; 73 | renderWithContext(); 74 | 75 | cy.findByText("Send Message").click(); 76 | cy.findByTestId("fdz-js-announcer") 77 | .should("have.text", customProps.message) 78 | .and("have.attr", "aria-live", customProps.politeness); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /cypress/test/hooks/useTabbable.spec.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license agreement. 3 | * 4 | * (c) 2021 Feedzai, Rights Reserved. 5 | */ 6 | import React from "react"; 7 | import { useTabbable } from "../../../src/hooks"; 8 | 9 | const ELEMENT_PROPS = { 10 | focusable: true, 11 | disabled: false, 12 | children: "An element content", 13 | "data-testid": "js-element", 14 | }; 15 | 16 | const DemoComponent = (hookProps: typeof ELEMENT_PROPS) => { 17 | const tabbableProps = useTabbable(hookProps); 18 | return ( 19 | 22 | ); 23 | }; 24 | 25 | describe("useTabbable", () => { 26 | let props: { 27 | focusable: boolean; 28 | disabled: boolean; 29 | children: string; 30 | "data-testid": string; 31 | }; 32 | 33 | beforeEach(() => { 34 | props = ELEMENT_PROPS; 35 | }); 36 | 37 | it("should disable an element semantically", () => { 38 | const CUSTOM_PROPS: typeof props = { 39 | ...props, 40 | disabled: true, 41 | }; 42 | 43 | cy.mount(); 44 | 45 | cy.findByRole("button").should("not.be.disabled").and("have.attr", "aria-disabled", "true"); 46 | }); 47 | 48 | it("should disable an element natively", () => { 49 | const CUSTOM_PROPS: typeof props = { 50 | ...props, 51 | focusable: false, 52 | disabled: true, 53 | }; 54 | 55 | cy.mount(); 56 | 57 | cy.findByRole("button").should("be.disabled").and("not.have.attr", "aria-disabled", "true"); 58 | }); 59 | 60 | context("should enable an element", () => { 61 | it("natively", () => { 62 | const CUSTOM_PROPS: typeof props = { 63 | ...props, 64 | focusable: false, 65 | disabled: false, 66 | }; 67 | 68 | cy.mount(); 69 | 70 | cy.findByRole("button") 71 | .should("not.be.disabled") 72 | .and("not.have.attr", "aria-disabled", "true"); 73 | }); 74 | 75 | it("focusable", () => { 76 | const CUSTOM_PROPS: typeof props = { 77 | ...props, 78 | focusable: true, 79 | disabled: false, 80 | }; 81 | 82 | cy.mount(); 83 | 84 | cy.findByRole("button") 85 | .should("not.be.disabled") 86 | .and("not.have.attr", "aria-disabled", "true"); 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/components/focus-manager/FocusManager.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2024 Feedzai, Rights Reserved. 6 | */ 7 | import React from "react"; 8 | import FocusTrap from "focus-trap-react"; 9 | import { FocusManagerProps } from "./types"; 10 | import { useFocusManager } from "./useFocusManager"; 11 | 12 | /** 13 | * A FocusManager manages focus for its descendants. It supports containing focus inside 14 | * the scope, restoring focus to the previously focused element on unmount, and auto 15 | * focusing children on mount. It also acts as a container for a programmatic focus 16 | * management interface that can be used to move focus forward and back in response 17 | * to user events. 18 | * 19 | * @example Default Usage 20 | * ```jsx 21 | * // a Focus Manager that contains focus, sets focus on the first tabbable element and restores focus when unmounting 22 | * import { FocusManager } from "@feedzai/react-a11y-tools"; 23 | * ... 24 | * function Component() { 25 | * return ( 26 | * 27 | * 28 | * 29 | * 30 | * 31 | * ); 32 | * } 33 | * ``` 34 | * 35 | * @example Restore focus to a different element 36 | * ```jsx 37 | * import { FocusManager } from "@feedzai/react-a11y-tools"; 38 | * ... 39 | * function Component() { 40 | * const fallbackElement = useRef(); 41 | * return ( 42 | *
43 | * 44 | * 45 | * 46 | * 47 | * 48 | * 49 | *
50 | * ); 51 | * } 52 | * ``` 53 | */ 54 | export function FocusManager({ 55 | children, 56 | contain = true, 57 | restoreFocus = true, 58 | autoFocus = true, 59 | options, 60 | }: FocusManagerProps): JSX.Element { 61 | const { fallbackRef, ...props } = useFocusManager({ 62 | contain, 63 | restoreFocus, 64 | autoFocus, 65 | options, 66 | }); 67 | 68 | return ( 69 | 70 |
71 | {children} 72 |
73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /cypress/e2e/focus-manager.cy.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | /* 5 | * Please refer to the terms of the license 6 | * agreement. 7 | * 8 | * (c) 2021 Feedzai, Rights Reserved. 9 | */ 10 | 11 | /** 12 | * focus-manager.spec.js 13 | * 14 | * @author João Dias 15 | * @since 1.0.0 16 | */ 17 | import { FOCUSABLE_ELEMENT_SELECTOR } from "../selectors/focusable"; 18 | 19 | const STORY_URL = "/docs/manage-focus/focus-manager"; 20 | 21 | describe("Focus Manager", () => { 22 | beforeEach(() => { 23 | cy.visit(STORY_URL); 24 | cy.findByTestId("fdz-js-docs-browser-window").as("preview"); 25 | 26 | cy.findByRole("button", { name: "Show Menu" }).as("MenuButton"); 27 | }); 28 | 29 | describe("Focus management", () => { 30 | beforeEach(() => { 31 | cy.get("@MenuButton").click(); 32 | cy.findByRole("dialog").as("Menu"); 33 | }); 34 | 35 | describe("focus trap", () => { 36 | it("should place focus on the first valid HTML element", () => { 37 | cy.get("@Menu").findByRole("button", { name: "Close" }).should("have.focus"); 38 | }); 39 | 40 | describe("roving tab index", () => { 41 | beforeEach(() => { 42 | cy.get("@Menu").within(() => { 43 | cy.get(`${FOCUSABLE_ELEMENT_SELECTOR}`).as("allFocusableElements"); 44 | }); 45 | }); 46 | 47 | describe("should place focus", () => { 48 | it("on the last menu item when the user presses shift+tab on the first element", () => { 49 | cy.get("@Menu").within(() => { 50 | cy.focused().realPress(["Shift", "Tab"]); 51 | cy.get("@allFocusableElements").last().should("have.focus"); 52 | }); 53 | }); 54 | 55 | it("on the first menu item when the user goes over the last element", () => { 56 | cy.get("@Menu").within(() => { 57 | cy.focused(); 58 | cy.tabUntil(() => cy.get("@allFocusableElements").first()); 59 | cy.get("@allFocusableElements").first().should("have.focus"); 60 | }); 61 | }); 62 | }); 63 | }); 64 | }); 65 | 66 | it("should restore focus when the focus-managerped menu closes", () => { 67 | cy.get("@MenuButton").click(); 68 | cy.get("@Menu").within(() => { 69 | cy.focused() 70 | .tabUntil(() => cy.findByRole("button", { name: "Close" })) 71 | .click(); 72 | }); 73 | cy.get("@MenuButton").should("have.focus"); 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/hooks/useFocusWithin/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * The copyright of this file belongs to Feedzai. The file cannot be 3 | * reproduced in whole or in part, stored in a retrieval system, transmitted 4 | * in any form, or by any means electronic, mechanical, or otherwise, without 5 | * the prior permission of the owner. Please refer to the terms of the license 6 | * agreement. 7 | * 8 | * (c) 2022 Feedzai, Rights Reserved. 9 | */ 10 | 11 | /** 12 | * index.ts 13 | * 14 | * @author João Dias 15 | * @since ```feedzai.next.release``` 16 | */ 17 | import { callIfExists } from "@feedzai/js-utilities"; 18 | import { FocusEvent, useCallback, useRef } from "react"; 19 | import { useSyntheticBlurEvent } from "./helpers"; 20 | import { UseFocusWithinProps, UseFocusWithinReturns } from "./types"; 21 | 22 | /** 23 | * Handles focus events for the target and its descendants. 24 | * 25 | * @export 26 | * @param {UseFocusWithinProps} { onBlurWithin, onFocusWithin, onFocusWithinChange } 27 | * @returns {UseFocusWithinReturns} 28 | */ 29 | export function useFocusWithin({ 30 | onBlurWithin, 31 | onFocusWithin, 32 | onFocusWithinChange, 33 | }: UseFocusWithinProps): UseFocusWithinReturns { 34 | const state = useRef({ 35 | isFocusWithin: false, 36 | }); 37 | 38 | const onBlur = useCallback( 39 | (event: FocusEvent) => { 40 | // We don't want to trigger onBlurWithin and then immediately onFocusWithin again 41 | // when moving focus inside the element. Only trigger if the currentTarget doesn't 42 | // include the relatedTarget (where focus is moving). 43 | if ( 44 | state.current.isFocusWithin && 45 | !(event.currentTarget as Element).contains(event.relatedTarget as Element) 46 | ) { 47 | state.current.isFocusWithin = false; 48 | 49 | callIfExists(onBlurWithin, event); 50 | callIfExists(onFocusWithinChange, false); 51 | } 52 | }, 53 | [onBlurWithin, onFocusWithinChange, state], 54 | ); 55 | 56 | const onSyntheticFocus = useSyntheticBlurEvent(onBlur); 57 | const onFocus = useCallback( 58 | (event: FocusEvent) => { 59 | if (!state.current.isFocusWithin) { 60 | callIfExists(onFocusWithin, event); 61 | callIfExists(onFocusWithinChange, true); 62 | 63 | state.current.isFocusWithin = true; 64 | onSyntheticFocus(event); 65 | } 66 | }, 67 | [onFocusWithin, onFocusWithinChange, onSyntheticFocus], 68 | ); 69 | 70 | return { 71 | focusWithinProps: { 72 | onFocus, 73 | onBlur, 74 | }, 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /cypress/test/components/visually-hidden/index.spec.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | /// 8 | /** 9 | * index.spec.tsx 10 | * 11 | * @author João Dias 12 | * @since 1.0.0 13 | */ 14 | import React from "react"; 15 | import { 16 | VisuallyHidden, 17 | VisuallyHiddenProps, 18 | DEFAULT_PROPS, 19 | } from "../../../../src/components/visually-hidden"; 20 | 21 | const DemoContent = ( 22 | props: VisuallyHiddenProps, 23 | ) => { 24 | return ( 25 |
33 | 36 |
37 | ); 38 | }; 39 | 40 | describe("", () => { 41 | let props = DEFAULT_PROPS; 42 | 43 | it("should render the text visually hidden", () => { 44 | cy.mount(); 45 | 46 | cy.findByRole("button").should("have.text", "Press Enter to Return to Navigation"); 47 | cy.findByTestId("fdz-js-visually-hidden") 48 | .should("exist") 49 | .and("have.css", "position", "absolute") 50 | .and("have.css", "clip", "rect(0px, 0px, 0px, 0px)") 51 | .and("have.css", "overflow", "hidden"); 52 | }); 53 | 54 | it("should render the component with a different tagname", () => { 55 | props = { 56 | ...props, 57 | as: "div", 58 | }; 59 | 60 | cy.mount(); 61 | 62 | cy.findByTestId("fdz-js-visually-hidden").should("have.prop", "tagName").should("eq", "DIV"); 63 | }); 64 | 65 | it("should render a set of html attributes", () => { 66 | props = { 67 | ...props, 68 | id: "60007f76-6d72-4a94-8161-16a7ea22e62b", 69 | className: "a-custom-classname", 70 | "data-custom-attribute": "custom-value", 71 | style: { 72 | accentColor: "hotpink", 73 | cursor: "pointer", 74 | }, 75 | }; 76 | 77 | cy.mount(); 78 | 79 | cy.findByTestId("fdz-js-visually-hidden") 80 | .and("have.class", "a-custom-classname") 81 | .and("have.css", "cursor", "pointer") 82 | .and("have.css", "accent-color", "rgb(255, 105, 180)") 83 | .and("have.attr", "data-custom-attribute", "custom-value"); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/components/announcer/announcer.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | 8 | /** 9 | * announcer.tsx 10 | * 11 | * @author João Dias 12 | * @since 1.0.0 13 | */ 14 | import React from "react"; 15 | import { isString } from "@feedzai/js-utilities"; 16 | 17 | export interface IAnnouncerProps { 18 | id?: string; 19 | "aria-live"?: React.AriaAttributes["aria-live"]; 20 | "aria-atomic"?: React.AriaAttributes["aria-atomic"]; 21 | styles?: React.CSSProperties; 22 | message?: string; 23 | } 24 | 25 | const styles: React.CSSProperties = { 26 | position: `absolute`, 27 | top: 0, 28 | width: 1, 29 | height: 1, 30 | padding: 0, 31 | overflow: `hidden`, 32 | clip: `rect(0, 0, 0, 0)`, 33 | whiteSpace: `nowrap`, 34 | border: 0, 35 | }; 36 | 37 | const defaultProps: Partial = { 38 | id: "fdz-js-route-announcer", 39 | styles, 40 | "aria-live": "assertive", 41 | "aria-atomic": "true", 42 | }; 43 | 44 | /** 45 | * Basic Announcer HTML element that tells a screen-reader of parts of the content that need the users attention. 46 | * Depending on the type of "politeness", users can be interrupted/or not by these messages. 47 | * 48 | * @example 49 | * // Changing page routes on a single-page application. 50 | * "Navigated to Create Account" 51 | * 52 | * // Status messages for form-related operations 53 | * "Your email is on the way!" 54 | * "Sorry, you need to fill the password field" 55 | * 56 | * // Notifications 57 | * "There's a new message" 58 | * "You're currently offline. Check your internet connection." 59 | * 60 | * @param {IAnnouncerProps} props 61 | * @returns {JSX.Element} 62 | */ 63 | export const Announcer = ({ 64 | id = defaultProps.id, 65 | styles = defaultProps.styles, 66 | "aria-live": ariaLive = defaultProps["aria-live"], 67 | "aria-atomic": ariaAtomic = defaultProps["aria-atomic"], 68 | message = defaultProps.message, 69 | }: IAnnouncerProps): JSX.Element => { 70 | /** 71 | * Renders the contents inside the announcer div 72 | * 73 | * @returns {string | null} 74 | */ 75 | function renderText() { 76 | if (!isString(message) || message.length === 0) { 77 | return null; 78 | } 79 | 80 | return

{message}

; 81 | } 82 | 83 | console.log; 84 | 85 | return ( 86 |
94 | {renderText()} 95 |
96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /documentation/docs/content-and-navigation/demos/semantic-headings.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Heading, Level } from "../../../../src/components/semantic-headings"; 3 | import styles from "./styles.module.scss"; 4 | 5 | export const DemoSemanticHeadings = () => { 6 | return ( 7 |
8 | 9 | Main Title 10 | 16 | 17 |
18 |

19 | Holy grail funding non-disclosure agreement advisor ramen bootstrapping ecosystem. 20 | Beta crowdfunding iteration assets business plan paradigm shift stealth mass market 21 | seed money rockstar niche market marketing buzz market. 22 |

23 |

24 | Burn rate release facebook termsheet equity technology. Interaction design rockstar 25 | network effects handshake creative startup direct mailing. Technology influencer 26 | direct mailing deployment return on investment seed round. 27 |

28 | A title 29 |

30 | Termsheet business model canvas user experience churn rate low hanging fruit backing 31 | iteration buyer seed money. Virality release launch party channels validation learning 32 | curve paradigm shift hypotheses conversion. Stealth leverage freemium venture startup 33 | business-to-business accelerator market. 34 |

35 | Another title 36 |
37 | Gen-z strategy long tail churn rate seed money channels user experience incubator 38 | startup partner network low hanging fruit direct mailing. Client backing success 39 | startup assets responsive web design burn rate A/B testing metrics first mover 40 | advantage conversion. 41 |
42 | 43 | And finally, another title 44 |

45 | Freemium non-disclosure agreement lean startup bootstrapping holy grail ramen MVP 46 | iteration accelerator. Strategy market ramen leverage paradigm shift seed round 47 | entrepreneur crowdfunding social proof angel investor partner network virality. 48 |

49 |
50 |
51 |
52 |
53 |
54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/components/visually-hidden/index.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license agreement. 3 | * 4 | * (c) 2021 Feedzai, Rights Reserved. 5 | */ 6 | 7 | /** 8 | * index.tsx 9 | * 10 | * @author João Dias 11 | * @since 1.0.0 12 | */ 13 | import React, { useRef } from "react"; 14 | import { makeId } from "@feedzai/js-utilities"; 15 | import { useAutoId } from "@feedzai/js-utilities/hooks"; 16 | import { CommonElement } from "../../typings/common"; 17 | import { PolymorphicComponentProps } from "../../typings/polymorphic"; 18 | 19 | /** 20 | * Styles to visually hide an element 21 | * but make it accessible to screen-readers 22 | */ 23 | export const visuallyHiddenStyle: React.CSSProperties = { 24 | border: "0px", 25 | clip: "rect(0px, 0px, 0px, 0px)", 26 | margin: "-1px", 27 | overflow: "hidden", 28 | height: "1px", 29 | width: "1px", 30 | padding: "0", 31 | position: "absolute", 32 | whiteSpace: "nowrap", 33 | }; 34 | 35 | export type VisuallyHiddenProps = PolymorphicComponentProps< 36 | C, 37 | CommonElement 38 | >; 39 | 40 | const DEFAULT_ELEMENT_TAG = "span"; 41 | 42 | export const DEFAULT_PROPS: Partial> = { 43 | "data-testid": "fdz-js-visually-hidden", 44 | }; 45 | 46 | /** 47 | * A utility component that can be used to hide its children visually, 48 | * while keeping them visible to screen readers and other assistive technology. 49 | * 50 | * @example 51 | * 52 | * // Rendering visually hidden text 53 | * 54 | * 55 | * // Visually, it will be rendered a button with the text: 56 | * "Return to Navigation" 57 | * 58 | * // But a screen-reader will read as: 59 | * "Press Enter to Return to Navigation.button" 60 | * 61 | * @param {React.FC>} props 62 | * @returns {JSX.Element} 63 | */ 64 | export const VisuallyHidden = ({ 65 | as, 66 | id, 67 | "data-testid": dataTestId = DEFAULT_PROPS["data-testid"], 68 | style, 69 | children, 70 | ...props 71 | }: VisuallyHiddenProps) => { 72 | const autoId = useAutoId(id); 73 | const { current: generatedId } = useRef(makeId(dataTestId, autoId)); 74 | const componentStyles: React.CSSProperties = { 75 | ...visuallyHiddenStyle, 76 | ...style, 77 | }; 78 | const Component = as || DEFAULT_ELEMENT_TAG; 79 | 80 | return ( 81 | 82 | {children} 83 | 84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /src/components/roving-tabindex/index.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* 3 | * Please refer to the terms of the license 4 | * agreement. 5 | * 6 | * (c) 2021 Feedzai, Rights Reserved. 7 | */ 8 | 9 | /** 10 | * index.ts 11 | * 12 | * @author João Dias 13 | * @since 1.0.0 14 | */ 15 | export type TKeyDirection = "horizontal" | "vertical" | "both"; 16 | 17 | export interface IRoverProviderProps { 18 | /** 19 | * Your content goes here 20 | */ 21 | children: React.ReactNode; 22 | 23 | /** 24 | * Depending on the direction ("vertical", "horizontal" or "both") it checks for the usage 25 | * of the `ArrowLeft`, `ArrowRight`, `ArrowUp` and `ArrowDown` 26 | */ 27 | direction?: TKeyDirection; 28 | } 29 | 30 | export type ActionTypes = 31 | | "REGISTER" 32 | | "UNREGISTER" 33 | | "TAB_TO_FIRST" 34 | | "TAB_TO_LAST" 35 | | "TAB_TO_PREVIOUS" 36 | | "TAB_TO_NEXT" 37 | | "CLICKED" 38 | | "CHANGE_DIRECTION"; 39 | 40 | export type TabStop = { 41 | readonly id: string; 42 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 43 | readonly domElementRef: React.RefObject; 44 | }; 45 | 46 | export type RovingState = { 47 | direction: TKeyDirection; 48 | selectedId: string | null; 49 | lastActionOrigin: "mouse" | "keyboard" | null; 50 | tabStops: Array; 51 | }; 52 | 53 | export interface IRovingActionPayloadID { 54 | id: TabStop["id"]; 55 | } 56 | 57 | export interface IRovingActionPayloadDirection { 58 | direction: TKeyDirection; 59 | } 60 | 61 | export interface IRovingActionWithPayload { 62 | type: Action; 63 | payload: Payload; 64 | } 65 | 66 | export interface IRovingAction { 67 | type: Action; 68 | } 69 | 70 | export type RovingAction = 71 | | IRovingActionWithPayload<"REGISTER", TabStop> 72 | | IRovingActionWithPayload<"UNREGISTER", IRovingActionPayloadID> 73 | | IRovingAction<"TAB_TO_FIRST"> 74 | | IRovingAction<"TAB_TO_LAST"> 75 | | IRovingActionWithPayload<"TAB_TO_PREVIOUS", IRovingActionPayloadID> 76 | | IRovingActionWithPayload<"TAB_TO_NEXT", IRovingActionPayloadID> 77 | | IRovingActionWithPayload<"CLICKED", IRovingActionPayloadID> 78 | | IRovingActionWithPayload<"UPDATE_SELECTED", IRovingActionPayloadID> 79 | | IRovingActionWithPayload<"CHANGE_DIRECTION", IRovingActionPayloadDirection>; 80 | 81 | export type RovingContext = { 82 | state: RovingState; 83 | dispatch: React.Dispatch; 84 | }; 85 | 86 | export { Provider as RoverProvider } from "./rover-provider/provider"; 87 | export { RoverConsumer } from "./rover-provider/consumer"; 88 | export { RoverContext } from "./rover-provider/context"; 89 | export * from "./use-rover"; 90 | export * from "./use-focus-effect"; 91 | -------------------------------------------------------------------------------- /src/components/focus-manager/types.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2024 Feedzai, Rights Reserved. 6 | */ 7 | import React, { RefObject } from "react"; 8 | import { Props as FocusTrapProps } from "focus-trap-react"; 9 | import { Options as FocusTrapOptions } from "focus-trap"; 10 | import { FocusTrap } from "focus-trap"; 11 | 12 | export type RestoreFocusAsOption = FocusTrapOptions["setReturnFocus"]; 13 | 14 | export interface FocusManagerProps { 15 | /** 16 | * Whether to contain focus inside the scope, so users cannot 17 | * move focus outside, for example in a dialog. 18 | * 19 | * @default true 20 | */ 21 | contain?: boolean | "paused"; 22 | 23 | /** 24 | * Whether to restore focus back to the element that was focused 25 | * when the focus scope mounted, after the focus scope unmounts. 26 | * 27 | * @default true 28 | */ 29 | restoreFocus?: true | RestoreFocusAsOption; 30 | 31 | /** 32 | * Whether to auto focus the first focusable element in the focus scope on mount 33 | * 34 | * @default true 35 | * */ 36 | autoFocus?: boolean; 37 | 38 | /** 39 | * Any type of children inside the FocusManager 40 | */ 41 | children: React.ReactNode; 42 | 43 | /** 44 | * Extends the functionality of the component using the `focus-trap` package options. 45 | * @see {@link https://github.com/focus-trap/focus-trap|FocusTrap documentation} 46 | */ 47 | options?: FocusTrapProps["focusTrapOptions"]; 48 | } 49 | 50 | export type UseFocusManagerWithRef = FocusTrap; 51 | export interface UseFocusManagerAsProps { 52 | /** 53 | * By default, an error will be thrown if the focus trap contains no elements in its tab order. 54 | * 55 | * With this option you can specify a fallback element to programmatically receive focus if no other tabbable elements are found. 56 | * 57 | * For example, you may want a popover's
to receive focus if the popover's content includes no tabbable elements. 58 | * Make sure the fallback element has a negative tabindex so it can be programmatically focused. 59 | */ 60 | fallbackRef: RefObject; 61 | 62 | /** 63 | * Pause an active focus trap's event listening without deactivating the trap. 64 | * If the focus trap has not been activated, nothing happens. 65 | * Returns the trap. 66 | * 67 | * Any `onDeactivate` callback will not be called, and focus will not return to the element that was focused before the trap's activation. 68 | * But the trap's behavior will be paused. 69 | * This is useful in various cases, one of which is when you want one focus trap within another. 70 | */ 71 | paused: boolean; 72 | 73 | /** 74 | * Props for the `FocusTrap` component 75 | */ 76 | focusTrapOptions: FocusTrapProps["focusTrapOptions"]; 77 | } 78 | -------------------------------------------------------------------------------- /documentation/docusaurus.config.ts: -------------------------------------------------------------------------------- 1 | import {themes as prismThemes} from 'prism-react-renderer'; 2 | import type {Config} from '@docusaurus/types'; 3 | import type * as Preset from '@docusaurus/preset-classic'; 4 | import { join, resolve } from 'path'; 5 | import packageJSON from "../package.json"; 6 | 7 | const config: Config = { 8 | title: "react-a11y-tools", 9 | tagline: "Focus on Accessible Web experiences", 10 | favicon: "img/favicon.svg", 11 | url: "https://feedzai.github.io", 12 | baseUrl: "/react-a11y-tools/", 13 | organizationName: "@feedzai", 14 | projectName: "react-a11y-tools", 15 | onBrokenLinks: 'throw', 16 | onBrokenMarkdownLinks: 'warn', 17 | i18n: { 18 | defaultLocale: 'en', 19 | locales: ['en'], 20 | }, 21 | customFields: { 22 | version: packageJSON.version, 23 | }, 24 | presets: [ 25 | [ 26 | 'classic', 27 | { 28 | docs: { 29 | sidebarPath: './sidebars.ts', 30 | editUrl: "https://github.com/feedzai/react-a11y-tools/", 31 | }, 32 | theme: { 33 | customCss: './src/css/custom.css', 34 | }, 35 | } satisfies Preset.Options, 36 | ], 37 | ], 38 | plugins: [ 39 | () => ({ 40 | name: "resolve-react", 41 | configureWebpack() { 42 | const NODE_MODULES = join(__dirname, "../node_modules"); 43 | const REACT = `${NODE_MODULES}/react`; 44 | const REACT_DOM = `${NODE_MODULES}/react-dom`; 45 | 46 | return { 47 | resolve: { 48 | alias: { 49 | react: resolve(REACT), 50 | "react-dom": resolve(REACT_DOM), 51 | }, 52 | }, 53 | }; 54 | }, 55 | }), 56 | "docusaurus-plugin-sass", 57 | [ 58 | "docusaurus-plugin-react-docgen-typescript", 59 | { 60 | src: ["../src/**/*.tsx"], 61 | global: true, 62 | parserOptions: { 63 | propFilter: (prop, component) => { 64 | if (prop.parent) { 65 | return !prop.parent.fileName.includes("@types/react"); 66 | } 67 | 68 | return true; 69 | }, 70 | }, 71 | }, 72 | ], 73 | ], 74 | themeConfig: { 75 | // Replace with your project's social card 76 | image: 'img/docusaurus-social-card.jpg', 77 | navbar: { 78 | title: "React a11y tools", 79 | logo: { 80 | alt: "", 81 | src: "img/favicon.svg", 82 | }, 83 | items: [ 84 | { 85 | href: "https://www.npmjs.com/package/@feedzai/react-a11y-tools", 86 | label: "NPM", 87 | position: "right", 88 | }, 89 | { 90 | href: "https://github.com/feedzai/react-a11y-tools", 91 | label: "GitHub", 92 | position: "right", 93 | }, 94 | ], 95 | }, 96 | prism: { 97 | theme: prismThemes.github, 98 | darkTheme: prismThemes.dracula, 99 | }, 100 | } satisfies Preset.ThemeConfig, 101 | }; 102 | 103 | export default config; 104 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-namespace */ 2 | /* 3 | * Please refer to the terms of the license 4 | * agreement. 5 | * 6 | * (c) 2021 Feedzai, Rights Reserved. 7 | */ 8 | 9 | /** 10 | * commands.ts 11 | * 12 | * @author João Dias 13 | * @since 1.0.0 14 | */ 15 | import "@testing-library/cypress/add-commands"; 16 | import "cypress-real-events/support"; 17 | import "./a11y"; 18 | import { isAriaDisabled } from "./a11y/assertions/isAriaDisabled"; 19 | import { recurse } from "cypress-recurse"; 20 | import { mount, unmount } from "cypress/react"; 21 | 22 | chai.use(isAriaDisabled); 23 | 24 | declare global { 25 | namespace Cypress { 26 | interface Chainable { 27 | tab(options?: Partial<{ shift: boolean }>): Chainable; 28 | 29 | /** 30 | * Presses the tab key until a predicate element is true. 31 | * It accepts a callback for finding the target element, and an optional shift element to tab backwards. 32 | * This commmand is specially useful to avoid chaining `.realPress("Tab")` multiple times before reaching an element. 33 | * 34 | * @requires `cypress-real-events` needs to be installed 35 | * 36 | * @example 37 | * 38 | * // Press tab until cypress finds the tab with the name "Transaction History" 39 | * cy.tabUntil(() => cy.getTab("Transaction History")); 40 | * 41 | * // Press tab until cypress finds the tab with the name "Transaction History", 42 | * // BUT travel backwards, using the `Shift+Tab` key combo 43 | * cy.tabUntil(() => cy.getTab("Transaction History", true)); 44 | */ 45 | tabUntil>>( 46 | element: () => GenericCallback, 47 | shift?: boolean, 48 | ): Cypress.Chainable>; 49 | 50 | mount: typeof mount; 51 | unmount: typeof unmount; 52 | } 53 | } 54 | } 55 | 56 | function tab( 57 | prevSubject: GenericSubject, 58 | options: Partial<{ shift: boolean }> = { shift: false }, 59 | ) { 60 | return cy.wrap(prevSubject).realPress(options.shift ? ["Shift", "Tab"] : "Tab"); 61 | } 62 | 63 | function tabUntil>>( 64 | getElement: () => GenericCallback, 65 | shift = false, 66 | ) { 67 | return recurse( 68 | () => getElement(), 69 | /** 70 | * Element assertion. 71 | * 72 | * @param {JQuery} $el 73 | * @returns {boolean} 74 | */ 75 | ($el: JQuery): boolean => $el.is(":focus"), 76 | { 77 | log: "Found the element!", 78 | post() { 79 | cy.focused().realPress(shift ? ["Shift", "Tab"] : "Tab"); 80 | }, 81 | }, 82 | ).should("have.focus"); 83 | } 84 | 85 | Cypress.Commands.add("tab", { prevSubject: ["element"] }, tab); 86 | 87 | Cypress.Commands.add("tabUntil", tabUntil); 88 | -------------------------------------------------------------------------------- /documentation/docs/getting-started.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | title: Getting Started 4 | --- 5 | 6 | ## About 7 | 8 | ### The Problem 9 | 10 | We are in the era of design systems. Every company - from the smallest to the largest - has one or has thought about making one. These are great, and they provide the tools for building quick, fast and consistent user interfaces. 11 | 12 | However, accessibility is still far from an easy task, especially when using libraries like React. Whether we developers use others' design systems or build our own from scratch, accessibility is still considered a "last-minute-addition". And, sometimes, companies don't even have the resources or time to make it a priority. 13 | 14 | ### The Solution 15 | 16 | Our goal is to make some parts of the accessibility process easier. Topics like focus management, navigation and announcements are all subjects we think can provide a helping hand. 17 | 18 | This library provides accessibility and behaviour following the WAI-ARIA Authoring Practices, including screen-reader and keyboard navigation support. 19 | 20 | We do not force any styling methodology or design-specific details. Instead, the little package provides the behaviour and interactions so that you can focus on your design. 21 | 22 | We also provide a simple testing tool so that you can emulate a no-mouse environment. 23 | 24 | ## Install 25 | 26 | Inside your React project directory, install `@feedzai/react-a11y-tools` by running either of the following: 27 | 28 | ```sh 29 | $ npm install @feedzai/react-a11y-tools 30 | 31 | # or if you use Yarn 32 | 33 | $ yarn add @feedzai/react-a11y-tools 34 | ``` 35 | 36 | ## Roadmap 37 | 38 | Here is a table of the components, custom hooks and their status. 39 | 40 | ✅ - Released
41 | 🛠 - Building
42 | 43 | | Status | Name | type | 44 | | ------------------- | ------------------------------------------------------------------- | ------------------------ | 45 | |
| [Route Announcer ](/docs/feedback/route-announcer) | `feedback` | 46 | |
| [Messages Announcer](/docs/feedback/messages-announcer) | `feedback` | 47 | |
| [Focus Manager](/docs/manage-focus/focus-manager) | `focus` | 48 | |
| [Rover Provider](/docs/manage-focus/rover-provider) | `focus` | 49 | |
| [Keyboard Only](/docs/testing/keyboard-only) | `testing utilities` | 50 | |
| [Skip Links](/docs/content-and-navigation/skip-links) | `content and navigation` | 51 | |
| [Semantic Headings](/docs/content-and-navigation/semantic-headings) | `content and navigation` | 52 | |
| [useTabbable](/docs/hooks/use-tabbable) | `hooks` | 53 | -------------------------------------------------------------------------------- /cypress/test/components/focus-manager/demos/MultipleManagers.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2024 Feedzai, Rights Reserved. 6 | */ 7 | import React from "react"; 8 | import { useState } from "react"; 9 | import { FocusManager } from "src/components"; 10 | import styles from "./MultipleManagers.module.scss"; 11 | 12 | export function MultipleManagers() { 13 | const [isOpen, setIsOpen] = useState(false); 14 | const [isSecondOpen, setIsSecondOpen] = useState(false); 15 | return ( 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 30 | 31 | 32 | 33 | 34 | 37 | 38 | 39 | 40 | 41 | 44 | 45 | 46 | 47 | 48 | 53 | 54 | 55 | 56 | 57 | 61 | 62 | 63 | 64 | 65 | 69 | 70 |
CompanyContactCountry
Alfreds FutterkisteMaria Anders 28 | 29 |
Centro comercial MoctezumaFrancisco Chang 35 | 36 |
Ernst HandelRoland Mendel 42 | 43 |
Island TradingHelen Bennett 49 | 52 |
Laughing Bacchus WinecellarsYoshi Tannamuri 58 | {" "} 59 | 60 |
Magazzini Alimentari RiunitiGiovanni Rovelli 66 | {" "} 67 | 68 |
71 | {isOpen ? ( 72 |
73 | 74 | 77 | 78 | 79 | 80 | 83 | 84 |
85 | ) : null} 86 | {isSecondOpen ? ( 87 |
88 | 89 | 92 | 93 | 94 | 95 | 96 |
97 | ) : null} 98 |
99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /src/components/focus-manager/useFocusManager.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | import { MutableRefObject, RefObject, useMemo, useRef } from "react"; 8 | import { createFocusTrap } from "focus-trap"; 9 | import { FocusManagerProps, UseFocusManagerAsProps, UseFocusManagerWithRef } from "./types"; 10 | import { getFallbackFocus, getInitialFocus, getReturnFocusProps } from "./helpers"; 11 | 12 | /** 13 | * Returns an FocusManager interface for the parent FocusManager. 14 | * A FocusManager can be used to programmatically move focus within 15 | * a FocusManager, e.g. in response to user events like keyboard navigation. 16 | * 17 | * @example 18 | * ```tsx 19 | * import { useFocusManager } from "@feedzai/react-a11y-tools"; 20 | * ... 21 | * const elementRef = useRef(null); 22 | * 23 | * useFocusManager(elementRef) 24 | * 25 | * return ( 26 | *
27 | * 28 | * 29 | * 30 | *
31 | * ) 32 | * ``` 33 | * 34 | */ 35 | export function useFocusManager( 36 | props: Omit, 37 | ref: RefObject | MutableRefObject, 38 | ): UseFocusManagerWithRef; 39 | export function useFocusManager( 40 | props: Omit, 41 | ref?: never, 42 | ): UseFocusManagerAsProps; 43 | export function useFocusManager( 44 | props: Omit, 45 | ref?: RefObject | MutableRefObject, 46 | ): UseFocusManagerWithRef | UseFocusManagerAsProps { 47 | const fallbackRef = useRef(null); 48 | 49 | /** 50 | * Returns the correct prop value for the return focus functionality. 51 | */ 52 | const returnFocusOptions = useMemo(() => { 53 | if (!props.restoreFocus) { 54 | return { 55 | returnFocusOnDeactivate: true, 56 | }; 57 | } 58 | 59 | return getReturnFocusProps(props.restoreFocus); 60 | }, [props.restoreFocus]); 61 | 62 | /** 63 | * Sets the fallback focus element to a: 64 | * 1. Defined option as prop 65 | * 2. A fallback ref element 66 | */ 67 | const fallbackFocus = useMemo(() => { 68 | return getFallbackFocus(fallbackRef.current, props.options?.fallbackFocus); 69 | }, [props.options?.fallbackFocus]); 70 | 71 | /** 72 | * Sets the initial focus element. 73 | */ 74 | const initialFocus = useMemo(() => { 75 | return getInitialFocus(props.autoFocus, props.options?.initialFocus); 76 | }, [props]); 77 | 78 | const FOCUS_TRAP_OPTIONS: FocusManagerProps["options"] = { 79 | ...props.options, 80 | ...returnFocusOptions, 81 | fallbackFocus, 82 | initialFocus, 83 | }; 84 | 85 | if (ref?.current) { 86 | return createFocusTrap(ref.current, FOCUS_TRAP_OPTIONS); 87 | } 88 | 89 | return { 90 | fallbackRef, 91 | paused: !!(props.contain === "paused" || props.contain === false), 92 | focusTrapOptions: FOCUS_TRAP_OPTIONS, 93 | } as UseFocusManagerAsProps; 94 | } 95 | -------------------------------------------------------------------------------- /cypress/support/a11y/checkA11y.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * The copyright of this file belongs to Feedzai. The file cannot be 3 | * reproduced in whole or in part, stored in a retrieval system, transmitted 4 | * in any form, or by any means electronic, mechanical, or otherwise, without 5 | * the prior permission of the owner. Please refer to the terms of the license 6 | * agreement. 7 | * 8 | * (c) 2022 Feedzai, Rights Reserved. 9 | */ 10 | 11 | /** 12 | * checkA11y.ts 13 | * 14 | * @author João Dias 15 | * @since ```feedzai.next.release``` 16 | */ 17 | import * as axe from "axe-core"; 18 | 19 | export interface Options extends axe.RunOptions { 20 | includedImpacts?: string[]; 21 | } 22 | 23 | function isEmptyObjectorNull(value: any) { 24 | if (value == null) { 25 | return true; 26 | } 27 | return Object.entries(value).length === 0 && value.constructor === Object; 28 | } 29 | 30 | const checkA11y = ( 31 | context?: axe.ElementContext, 32 | options?: Options, 33 | violationCallback?: (violations: axe.Result[]) => void, 34 | skipFailures = false, 35 | ) => { 36 | cy.window({ log: false }) 37 | .then((win) => { 38 | if (isEmptyObjectorNull(context)) { 39 | context = undefined; 40 | } 41 | 42 | if (isEmptyObjectorNull(options)) { 43 | options = undefined; 44 | } 45 | 46 | if (isEmptyObjectorNull(violationCallback)) { 47 | violationCallback = undefined; 48 | } 49 | 50 | const { includedImpacts, ...axeOptions } = options || {}; 51 | 52 | return win.axe.run(context || win.document, axeOptions).then(({ violations }) => { 53 | return includedImpacts && Array.isArray(includedImpacts) && Boolean(includedImpacts.length) 54 | ? violations.filter((v) => v.impact && includedImpacts.includes(v.impact)) 55 | : violations; 56 | }); 57 | }) 58 | .then((violations) => { 59 | if (violations.length) { 60 | if (violationCallback) { 61 | violationCallback(violations); 62 | } 63 | 64 | violations.forEach((v) => { 65 | const selectors = v.nodes.reduce((acc, node) => acc.concat(node.target), []).join(", "); 66 | 67 | Cypress.log({ 68 | $el: Cypress.$(selectors), 69 | name: "a11y error!", 70 | consoleProps: () => v, 71 | message: `${v.id} on ${v.nodes.length} Node${v.nodes.length === 1 ? "" : "s"}`, 72 | }); 73 | }); 74 | } 75 | 76 | return cy.wrap(violations, { log: false }); 77 | }) 78 | .then((violations) => { 79 | if (!skipFailures) { 80 | assert.equal( 81 | violations.length, 82 | 0, 83 | `${violations.length} accessibility violation${violations.length === 1 ? "" : "s"} ${violations.length === 1 ? "was" : "were" 84 | } detected`, 85 | ); 86 | } else if (violations.length) { 87 | Cypress.log({ 88 | name: "a11y violation summary", 89 | message: `${violations.length} accessibility violation${violations.length === 1 ? "" : "s"} ${violations.length === 1 ? "was" : "were" 90 | } detected`, 91 | }); 92 | } 93 | }); 94 | }; 95 | 96 | export default checkA11y; 97 | -------------------------------------------------------------------------------- /documentation/docs/hooks/demos/useTabbable/index.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * The copyright of this file belongs to Feedzai. The file cannot be 3 | * reproduced in whole or in part, stored in a retrieval system, transmitted 4 | * in any form, or by any means electronic, mechanical, or otherwise, without 5 | * the prior permission of the owner. Please refer to the terms of the license 6 | * agreement. 7 | * 8 | * (c) 2021 Feedzai, Rights Reserved. 9 | */ 10 | 11 | /** 12 | * index.tsx 13 | * 14 | * @author João Dias 15 | * @since ```feedzai.next.release``` 16 | */ 17 | import React, { useRef, useState, HTMLAttributes, InputHTMLAttributes } from "react"; 18 | import { useTabbable } from "../../../../../src/index"; 19 | import { makeId } from "@feedzai/js-utilities"; 20 | import { useAutoId } from "@feedzai/js-utilities/hooks"; 21 | import styles from "./index.modules.scss"; 22 | 23 | type Tabbable = { 24 | focusable?: boolean; 25 | disabled?: boolean; 26 | }; 27 | 28 | type Button = HTMLAttributes & Tabbable; 29 | type Input = InputHTMLAttributes & 30 | Tabbable & { 31 | label: string; 32 | }; 33 | 34 | const Button = ({ id, disabled, ...props }: Button) => { 35 | const autoId = useAutoId(id); 36 | const { current: generatedId } = useRef(makeId("fdz-js-tabbable-button-", autoId)); 37 | const { focusable, ...htmlProps } = useTabbable 46 | ); 47 | }; 48 | 49 | const Input = ({ id, disabled, ...props }: Input) => { 50 | const autoId = useAutoId(id); 51 | const { current: generatedId } = useRef(makeId("fdz-js-tabbable-", autoId)); 52 | const { label, focusable, ...htmlProps } = useTabbable({ 53 | ...props, 54 | disabled, 55 | }); 56 | 57 | return ( 58 |
59 | 62 | 63 |
64 | ); 65 | }; 66 | 67 | export const DemoUseTabbable = () => { 68 | const [value, setValue] = useState("John Doe"); 69 | return ( 70 |
71 |
72 | 73 | 76 | 79 |
80 |
81 | setValue(event.currentTarget.value)} 85 | name="tabbable" 86 | value={value} 87 | /> 88 | 97 |
98 |
99 | ); 100 | }; 101 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | storybook-static 3 | 4 | ### Node ### 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage/ 20 | .sonar/ 21 | coverage-reports 22 | .nyc_output 23 | cypress/results 24 | cypress/screenshots 25 | cypress/downloads 26 | 27 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (http://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directory 37 | node_modules 38 | 39 | # Optional npm cache directory 40 | .npm 41 | 42 | # Optional REPL history 43 | .node_repl_history 44 | 45 | 46 | ### WebStorm ### 47 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 48 | 49 | *.iml 50 | 51 | ## Directory-based project format: 52 | .idea/ 53 | .vscode/ 54 | # if you remove the above rule, at least ignore the following: 55 | 56 | # User-specific stuff: 57 | # .idea/workspace.xml 58 | # .idea/tasks.xml 59 | # .idea/dictionaries 60 | # .idea/shelf 61 | 62 | # Sensitive or high-churn files: 63 | # .idea/dataSources.ids 64 | # .idea/dataSources.xml 65 | # .idea/sqlDataSources.xml 66 | # .idea/dynamic.xml 67 | # .idea/uiDesigner.xml 68 | 69 | # Gradle: 70 | # .idea/gradle.xml 71 | # .idea/libraries 72 | 73 | # Mongo Explorer plugin: 74 | # .idea/mongoSettings.xml 75 | 76 | ## File-based project format: 77 | *.ipr 78 | *.iws 79 | 80 | ## Plugin-specific files: 81 | 82 | # IntelliJ 83 | /out/ 84 | 85 | # mpeltonen/sbt-idea plugin 86 | .idea_modules/ 87 | 88 | # JIRA plugin 89 | atlassian-ide-plugin.xml 90 | 91 | # Crashlytics plugin (for Android Studio and IntelliJ) 92 | com_crashlytics_export_strings.xml 93 | crashlytics.properties 94 | crashlytics-build.properties 95 | fabric.properties 96 | 97 | 98 | ### Linux ### 99 | *~ 100 | 101 | # temporary files which can be created if a process still has a handle open of a deleted file 102 | .fuse_hidden* 103 | 104 | # KDE directory preferences 105 | .directory 106 | 107 | # Linux trash folder which might appear on any partition or disk 108 | .Trash-* 109 | 110 | ### Mac OS files ### 111 | .DS_Store 112 | 113 | ### SublimeText ### 114 | # cache files for sublime text 115 | *.tmlanguage.cache 116 | *.tmPreferences.cache 117 | *.stTheme.cache 118 | 119 | # workspace files are user-specific 120 | *.sublime-workspace 121 | 122 | # project files should be checked into the repository, unless a significant 123 | # proportion of contributors will probably not be using SublimeText 124 | # *.sublime-project 125 | 126 | # sftp configuration file 127 | sftp-config.json 128 | 129 | 130 | ### Our compiled code ### 131 | dist/ 132 | public/ 133 | styleguide/ 134 | 135 | ### Our Coverage Reports ### 136 | coverage/ 137 | .sonar/ 138 | styleguide/ 139 | 140 | bundlestats.json 141 | 142 | node_modules/ 143 | jest-coverage 144 | coverage/ 145 | .sonar/ 146 | coverage-reports 147 | .nyc_output 148 | cypress/results 149 | cypress/screenshots 150 | reports 151 | results 152 | .env 153 | snapshots.js 154 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { resolve } from "node:path"; 3 | import type { RollupOptions } from "rollup"; 4 | import { InlineConfig } from "vitest"; 5 | import { defineConfig } from "vite"; 6 | import react from "@vitejs/plugin-react-swc"; 7 | import dts from "vite-plugin-dts"; 8 | import IstanbulPlugin from "vite-plugin-istanbul"; 9 | import tsconfigPaths from "vite-tsconfig-paths"; 10 | 11 | type ModuleFormat = 12 | | "amd" 13 | | "cjs" 14 | | "es" 15 | | "iife" 16 | | "system" 17 | | "umd" 18 | | "commonjs" 19 | | "esm" 20 | | "module" 21 | | "systemjs"; 22 | 23 | const BASE_EXTERNAL_LIBRARIES = { 24 | react: "React", 25 | "react-dom": "ReactDOM", 26 | "react/jsx-runtime": "react/jsx-runtime", 27 | "@feedzai/js-utilities": "JSUtilities", 28 | "focus-trap-react": "FocusTrapReact", 29 | }; 30 | 31 | const ROLLUP_OPTIONS: RollupOptions = { 32 | external: [...Object.keys(BASE_EXTERNAL_LIBRARIES)], 33 | output: { 34 | globals: { 35 | ...BASE_EXTERNAL_LIBRARIES, 36 | }, 37 | }, 38 | }; 39 | 40 | /** 41 | * Gets a per-file format filename. 42 | * 43 | * @param format 44 | * @returns 45 | */ 46 | function getFilename(format: ModuleFormat) { 47 | const OUTPUT: Partial> = { 48 | es: `index.mjs`, 49 | cjs: `index.cjs`, 50 | }; 51 | 52 | return OUTPUT[format] ?? `index.cjs`; 53 | } 54 | 55 | const VITEST_CONFIG: InlineConfig = { 56 | globals: true, 57 | environment: "jsdom", 58 | include: ["./test/vitest/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], 59 | setupFiles: "./config/setupVitest.ts", 60 | coverage: { 61 | all: true, 62 | enabled: true, 63 | reporter: ["html", "json", "text-summary"], 64 | provider: "istanbul", 65 | reportsDirectory: "./coverage-reports/vitest", 66 | exclude: [ 67 | "coverage/**", 68 | "dist/**", 69 | "**/[.]**", 70 | "packages/*/test?(s)/**", 71 | "**/*.d.ts", 72 | "**/virtual:*", 73 | "**/__x00__*", 74 | "**/\x00*", 75 | "test/cypress/**", 76 | "test?(s)/**", 77 | "test?(-*).?(c|m)[jt]s?(x)", 78 | "**/*{.,-}{test,spec}.?(c|m)[jt]s?(x)", 79 | "**/__tests__/**", 80 | "**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*", 81 | "**/vitest.{workspace,projects}.[jt]s?(on)", 82 | "**/.{eslint,mocha,prettier}rc.{?(c|m)js,yml}", 83 | "documentation/**", 84 | "coverage-reports/**", 85 | "coverage/**", 86 | ], 87 | }, 88 | }; 89 | 90 | // https://vitejs.dev/config/ 91 | export default defineConfig({ 92 | plugins: [ 93 | dts({ 94 | insertTypesEntry: true, 95 | }), 96 | IstanbulPlugin({ 97 | cypress: true, 98 | requireEnv: false, 99 | }), 100 | tsconfigPaths(), 101 | react(), 102 | ], 103 | resolve: { 104 | alias: [ 105 | { 106 | find: "src", 107 | replacement: resolve(__dirname, "./src"), 108 | }, 109 | ], 110 | }, 111 | css: { 112 | modules: { 113 | generateScopedName: "fdz-css-[hash:base64:8]", 114 | }, 115 | }, 116 | build: { 117 | minify: true, 118 | lib: { 119 | entry: resolve(__dirname, "src/index.ts"), 120 | name: "ReactA11yTools", 121 | formats: ["es", "cjs"], 122 | fileName: getFilename, 123 | }, 124 | rollupOptions: ROLLUP_OPTIONS, 125 | }, 126 | test: VITEST_CONFIG, 127 | }); 128 | -------------------------------------------------------------------------------- /cypress/test/components/skip-links/index.spec.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | /** 8 | * index.spec.tsx 9 | * 10 | * @author João Dias 11 | * @since 1.0.0 12 | */ 13 | import React from "react"; 14 | import { 15 | SkipLinks, 16 | SKIP_LINK_ITEMS_DEFAULT_PROPS, 17 | } from "../../../../src/components/skip-links/index"; 18 | import { ISkipLinksProps } from "../../../../src/components/skip-links"; 19 | 20 | const DemoContent = (props: ISkipLinksProps) => { 21 | return ( 22 |
30 | 31 |
41 |

Main Content Area

42 |
43 |
44 | ); 45 | }; 46 | 47 | describe("", () => { 48 | let props = SKIP_LINK_ITEMS_DEFAULT_PROPS; 49 | 50 | it("should render a link even if items is undefined", () => { 51 | cy.mount(); 52 | cy.findByRole("link", { name: props.items[0].text }).should("exist"); 53 | }); 54 | 55 | it("should render a list of skip links", () => { 56 | cy.mount(); 57 | cy.findAllByRole("link", { name: props.items[0].text }).should("exist"); 58 | }); 59 | 60 | describe("", () => { 61 | beforeEach(() => { 62 | props = { 63 | items: [ 64 | { 65 | target: "#main-content", 66 | text: "Skip to Main Content", 67 | as: "button", 68 | }, 69 | { 70 | target: "#sidebar", 71 | text: "Skip to Side Menu", 72 | }, 73 | ], 74 | }; 75 | }); 76 | 77 | describe("Placing focus on target", () => { 78 | beforeEach(() => { 79 | // Render the demo content 80 | cy.mount(); 81 | }); 82 | 83 | describe("Should place Focus", () => { 84 | it("when using the ENTER key", () => { 85 | // Press enter on the first skip link 86 | cy.get("body").click().realPress("Tab"); 87 | cy.focused().realPress("{enter}"); 88 | cy.findByRole("main").should("have.focus"); 89 | }); 90 | 91 | it("when using the SPACE key", () => { 92 | // Press space on the first skip link 93 | cy.get("body").click().realPress("Tab"); 94 | cy.focused().realPress(" "); 95 | cy.findByRole("main").should("have.focus"); 96 | }); 97 | }); 98 | 99 | it("should not place focus, when pressing any other key on a skip link", () => { 100 | // Press a number of keys on the first skip link 101 | cy.get("body").click().realPress("Tab"); 102 | cy.focused().type("a random sentence"); 103 | cy.get("body").click().realPress("Tab"); 104 | cy.focused().realPress("{backspace}"); 105 | cy.get("body").click().realPress("Tab"); 106 | cy.focused().realPress("{del}"); 107 | cy.get("body").click().realPress("Tab"); 108 | cy.focused().type("{downarrow}"); 109 | cy.findByRole("main").should("not.have.focus"); 110 | }); 111 | }); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /src/helpers/focus-without-scrolling.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | /* 3 | * Please refer to the terms of the license 4 | * agreement. 5 | * 6 | * (c) 2021 Feedzai, Rights Reserved. 7 | */ 8 | 9 | /** 10 | * focus-without-scrolling.ts 11 | * 12 | * @author João Dias 13 | * @since 1.0.0 14 | */ 15 | interface IScrollableElement { 16 | element: HTMLElement; 17 | scrollTop: number; 18 | scrollLeft: number; 19 | } 20 | 21 | /** 22 | * @param {HTMLElement} element 23 | * @returns {IScrollableElement[]} 24 | */ 25 | function getAllScrollableElements( 26 | element: GenericElement, 27 | ): IScrollableElement[] { 28 | let parent = element.parentNode; 29 | const IScrollableElements: IScrollableElement[] = []; 30 | const rootScrollingElement = document.scrollingElement || document.documentElement; 31 | 32 | while (parent instanceof HTMLElement && parent !== rootScrollingElement) { 33 | if (parent.offsetHeight < parent.scrollHeight || parent.offsetWidth < parent.scrollWidth) { 34 | IScrollableElements.push({ 35 | element: parent, 36 | scrollTop: parent.scrollTop, 37 | scrollLeft: parent.scrollLeft, 38 | }); 39 | } 40 | parent = parent.parentNode; 41 | } 42 | 43 | if (rootScrollingElement instanceof HTMLElement) { 44 | IScrollableElements.push({ 45 | element: rootScrollingElement, 46 | scrollTop: rootScrollingElement.scrollTop, 47 | scrollLeft: rootScrollingElement.scrollLeft, 48 | }); 49 | } 50 | 51 | return IScrollableElements; 52 | } 53 | 54 | /** 55 | * @param {IScrollableElement[]} IScrollableElements 56 | */ 57 | function restoreScrollPosition(IScrollableElements: IScrollableElement[]) { 58 | for (const { element, scrollTop, scrollLeft } of IScrollableElements) { 59 | element.scrollTop = scrollTop; 60 | element.scrollLeft = scrollLeft; 61 | } 62 | } 63 | 64 | /** 65 | * Places focus on an `HTMLElement` or `SVGElement` without causing a page scroll. 66 | * 67 | * @example 68 | * 69 | * // Placing focus on a button 70 | * const element = document.querySelector("button.some-css-class"); 71 | * 72 | * if (element) { 73 | * focusWithoutScrolling(element); 74 | * } 75 | * @export 76 | * @param {HTMLElement} element 77 | */ 78 | export function focusWithoutScrolling( 79 | element: GenericElement, 80 | ): void { 81 | if (supportsPreventScroll()) { 82 | element.focus({ preventScroll: true }); 83 | } else { 84 | const allScrollableElements = getAllScrollableElements(element); 85 | element.focus(); 86 | restoreScrollPosition(allScrollableElements); 87 | } 88 | } 89 | 90 | let supportsPreventScrollCached: boolean | null = null; 91 | 92 | function supportsPreventScroll() { 93 | if (supportsPreventScrollCached == null) { 94 | supportsPreventScrollCached = false; 95 | try { 96 | const focusElem = document.createElement("div"); 97 | focusElem.focus({ 98 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 99 | // @ts-ignore 100 | get preventScroll() { 101 | supportsPreventScrollCached = true; 102 | return true; 103 | }, 104 | }); 105 | } catch (e) { 106 | // Ignore 107 | } 108 | } 109 | 110 | return supportsPreventScrollCached; 111 | } 112 | -------------------------------------------------------------------------------- /cypress/e2e/roving-tabindex.cy.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /* 4 | * Please refer to the terms of the license 5 | * agreement. 6 | * 7 | * (c) 2021 Feedzai, Rights Reserved. 8 | */ 9 | 10 | /** 11 | * roving-tabindex.spec.js 12 | * 13 | * @author João Dias 14 | * @since 1.0.0 15 | */ 16 | import { FOCUSABLE_ELEMENT_SELECTOR } from "../selectors/focusable"; 17 | 18 | const ROVER_STORY_URL = "/docs/manage-focus/rover-provider"; 19 | 20 | describe("Roving Tab Index", () => { 21 | beforeEach(() => { 22 | cy.visit(ROVER_STORY_URL); 23 | cy.findByRole("button", { name: "A Button" }).as("FirstButton"); 24 | cy.findByRole("button", { name: "Another Button" }).as("SecondButton"); 25 | cy.findByTestId("fdz-js-roving-tabindex-sidenav").as("Nav"); 26 | }); 27 | 28 | it("should not use tab as a means to travel through the menu", () => { 29 | cy.get("@FirstButton") 30 | .focus() 31 | .tabUntil(() => cy.get("@SecondButton")); 32 | cy.get("@SecondButton").should("have.focus"); 33 | }); 34 | 35 | it("should go through the menu using the down arrow button", () => { 36 | cy.get("@FirstButton").focus().realPress("Tab"); 37 | cy.get("@Nav").within(() => { 38 | cy.get(FOCUSABLE_ELEMENT_SELECTOR).as("navButtons"); 39 | cy.get("@navButtons").first().should("have.focus"); 40 | cy.focused().type("{downarrow}{downarrow}{downarrow}{downarrow}{downarrow}"); 41 | cy.get("@navButtons").last().should("have.focus"); 42 | }); 43 | cy.focused().realPress("Tab"); 44 | cy.get("@SecondButton").should("have.focus"); 45 | }); 46 | 47 | it("should travel to the top of the menu when pressing the Home button", () => { 48 | cy.get("@FirstButton").focus().realPress("Tab"); 49 | cy.get("@Nav").within(() => { 50 | cy.get(FOCUSABLE_ELEMENT_SELECTOR).as("navButtons"); 51 | cy.get("@navButtons").first().should("have.focus"); 52 | cy.focused().type("{downarrow}{downarrow}{downarrow}{downarrow}{home}"); 53 | cy.get("@navButtons").first().should("have.focus"); 54 | }); 55 | }); 56 | 57 | it("should travel to the bottom of the menu when pressing the End button", () => { 58 | cy.get("@FirstButton").focus().realPress("Tab"); 59 | cy.get("@Nav").within(() => { 60 | cy.get(FOCUSABLE_ELEMENT_SELECTOR).as("navButtons"); 61 | cy.get("@navButtons").first().should("have.focus"); 62 | cy.focused().type("{downarrow}{downarrow}{downarrow}{downarrow}{home}"); 63 | cy.get("@navButtons").first().should("have.focus"); 64 | }); 65 | }); 66 | 67 | it("should travel to the bottom of the menu when pressing the End button", () => { 68 | cy.get("@FirstButton").focus().realPress("Tab"); 69 | cy.get("@Nav").within(() => { 70 | cy.get(FOCUSABLE_ELEMENT_SELECTOR).as("navButtons"); 71 | cy.get("@navButtons").first().should("have.focus"); 72 | cy.focused().type("{downarrow}{end}"); 73 | cy.get("@navButtons").last().should("have.focus"); 74 | }); 75 | }); 76 | 77 | it("should not do anything if pressed a non-mapped key", () => { 78 | cy.get("@FirstButton").focus().realPress("Tab"); 79 | cy.get("@Nav").within(() => { 80 | cy.get(FOCUSABLE_ELEMENT_SELECTOR).as("navButtons"); 81 | cy.get("@navButtons").first().should("have.focus"); 82 | cy.focused().type("{del}{insert}{movetoend}{pageup}{pagedown}{rightarrow}"); 83 | cy.get("@navButtons").first().should("have.focus"); 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /documentation/docs/feedback/messages-announcer.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | title: Messages Announcer 4 | --- 5 | 6 | import { MessagesAnnouncer } from "../../../src/index"; 7 | import { Subtitle, BrowserWindow } from "../../src/components"; 8 | import { DemoMessagesAnnouncer } from "./demos"; 9 | 10 | Announce content changes. 11 | 12 | It is common on single-page web apps that assistive technologies completely ignore significant changes happening inside a page, mainly because we manipulate the DOM with JavasScript. 13 | 14 | Like the `RouteAnnouncer`, with the `MessagesAnnoucer`, we create an ARIA live region that the assistive technologies will monitor. So, any changes that you push to this component, a screen-reader will announce them. 15 | 16 | 17 | 18 | 19 |
20 | 21 | :::note How to 22 | 23 | - Start your screen-reader (like `VoiceOver` or `Narrator`) 24 | - Focus on the "Pay" button. 25 | - Notice that a message will be displayed. 26 | - However, the screen-reader will output a different and more meaningful message. 27 | ::: 28 | 29 | ### Usage 30 | 31 | You can use component to wrap the contents of a form, for instance. 32 | This renders the form and, alongside it, the ARIA live region. 33 | 34 | ```jsx 35 | import { MessagesAnnouncer } from "@feedzai/react-a11y-tools"; 36 | ... 37 | return ( 38 | 39 |
40 | ... 41 |
42 |
43 | ); 44 | ``` 45 | 46 | ### useMessageAnnouncer 47 | 48 | To dispatch messages inside a functional component, you can take advantage of the `useMessageAnnouncer` custom hook. 49 | 50 | First, import the hook at the top of the file: 51 | 52 | ```jsx 53 | import { useMessageAnnouncer } from "@feedzai/react-a11y-tools"; 54 | ``` 55 | 56 | Then, after you've defined your component, save the hook to a constant: 57 | 58 | ```jsx 59 | function YourComponent(props) { 60 | const setMessage = useMessageAnnouncer(); 61 | ``` 62 | 63 | Finally, use the function whenever you'd like to announce something. 64 | 65 | ```jsx 66 | function onClick() { 67 | setMessage({ 68 | message: "The user has performed an action that it will be announced!", 69 | politeness: "assertive", 70 | }); 71 | } 72 | 73 | return ( 74 | 77 | ); 78 | ``` 79 | 80 | ### Global State 81 | 82 | The component takes advantage of React's `Context` API to use the updater function anywhere inside the `MessagesAnnouncer` children. 83 | To have the announcer available for the whole app, consider wrapping the content with it. This way, you can access the `setMessage` function from any component inside. 84 | 85 | ```jsx 86 | import { MessagesAnnouncer } from "@feedzai/react-a11y-tools"; 87 | ... 88 | return ( 89 | 90 | 91 | 92 | ); 93 | ``` 94 | 95 | ### Working alongside Route Announcer 96 | 97 | You can use the `MessageAnnouncer` along with the `RouteAnnouncer`. 98 | However, we recommend that you still place the `RouteAnnouncer` at the top and then inside it, the `MessagesAnnouncer`. 99 | 100 | ```jsx 101 | import { MessagesAnnouncer } from "@feedzai/react-a11y-tools"; 102 | ... 103 | return ( 104 | 105 | 106 | 107 | 108 | 109 | ); 110 | ``` 111 | -------------------------------------------------------------------------------- /documentation/docs/content-and-navigation/demos/skip-links.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { options } from "./mocks"; 3 | import { SkipLinks, Heading } from "../../../../src"; 4 | import { ISkipLink } from "../../../../src/components/skip-links/link"; 5 | import styles from "./styles.module.scss"; 6 | 7 | export const DemoSkipLinks = () => { 8 | return ( 9 | <> 10 | 13 |
14 | 15 |
16 |
17 |

Skip Links Demo

18 | 42 |
43 |
49 | Bacon Ipsum 50 |

51 | B eef ribs leberkas 52 | capicola cow swine turducken tri-tip meatball drumstick short ribs. Meatball boudin 53 | ribeye tri-tip pancetta, filet mignon kevin venison turducken pork loin. Porchetta 54 | buffalo kevin capicola tail pancetta shank corned beef bacon tongue beef ribs cow. 55 | Picanha ham hock ribeye sausage. 56 |
57 |
58 | Chislic cupim pig, hamburger jerky ribeye andouille sausage buffalo. Tenderloin t-bone 59 | beef pancetta boudin short ribs pork loin kevin, spare ribs corned beef picanha filet 60 | mignon hamburger frankfurter ham hock. Prosciutto chuck chislic hamburger, pork loin 61 | meatloaf biltong tongue ham hock strip steak pork belly turkey venison boudin kevin. 62 | Sirloin capicola kevin ham spare ribs tri-tip swine leberkas pork loin cupim chislic 63 | jowl. Shoulder short loin pork loin, filet mignon biltong venison beef spare ribs 64 | tongue sausage. T-bone porchetta sausage picanha landjaeger shank venison beef bacon 65 | shankle rump. 66 |

67 | 74 | Repository on Github 75 | 76 | 83 | Package on npm 84 | 85 |
86 |
87 |
88 | 89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /documentation/docs/manage-focus/demos/focus-manager.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { FocusManager } from "../../../../src/index"; 3 | import styles from "./styles.module.scss"; 4 | 5 | export const DemoFocusManager = (props) => { 6 | const [isVisible, setIsVisible] = useState(false); 7 | return ( 8 |
9 | 16 | {isVisible && ( 17 | 18 |
19 |
24 |
25 |
26 |

27 | Menu 28 |

29 | 36 |
37 | 71 |
72 |
73 | 79 | 94 |
95 |
96 |
97 |
98 |
99 |
100 | )} 101 |
102 | ); 103 | }; 104 | -------------------------------------------------------------------------------- /src/components/semantic-headings/index.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license agreement. 3 | * 4 | * (c) 2021 Feedzai, Rights Reserved. 5 | */ 6 | 7 | /** 8 | * index.tsx 9 | * 10 | * @author João Dias 11 | * @since 1.0.0 12 | */ 13 | 14 | import React, { 15 | Ref, 16 | forwardRef, 17 | createElement, 18 | useContext, 19 | PropsWithChildren, 20 | DetailedHTMLProps, 21 | HTMLAttributes, 22 | } from "react"; 23 | import { getHeadingLevel } from "./helpers"; 24 | import { HeadingsContext } from "./context"; 25 | 26 | export interface ILevelProps { 27 | /** 28 | * Overrides the default heading level. 29 | * Use this prop with caution. 30 | * 31 | * @example 32 | * 33 | * // Rendering a Heading as an h3 instead of an h2. 34 | * 35 | * I'm a title! 36 | * 37 | * 38 | * // Instead of rendering 39 | *

I'm a title

40 | * 41 | * // Would render as: 42 | *

I'm a title

43 | */ 44 | dangerouslySetHeadingLevel?: number; 45 | } 46 | 47 | export type HeadingProps = { 48 | /** 49 | * Level relative to the current one 50 | * 51 | * @example 52 | * // Offseting the heading level 53 | * title 54 | * subtitle 55 | * 56 | * // Which is similar to writing levels with the `Level`: 57 | *

Main title

58 | * title 59 | * 60 | * subtitle 61 | * 62 | * 63 | * // And this renders 64 | *

Main title

65 | *

title

66 | *

subtitle

67 | */ 68 | offset?: number; 69 | } & DetailedHTMLProps, HTMLHeadingElement>; 70 | 71 | /** 72 | * Creates a new heading level depth and increments the current heading level for all children using the `` component. 73 | * 74 | * @param {PropsWithChildren} props 75 | * @returns 76 | */ 77 | export function Level({ dangerouslySetHeadingLevel, children }: PropsWithChildren) { 78 | const current = useContext(HeadingsContext); 79 | const value = getHeadingLevel( 80 | dangerouslySetHeadingLevel !== undefined ? dangerouslySetHeadingLevel : current + 1, 81 | ); 82 | return {children}; 83 | } 84 | 85 | /** 86 | * Renders a heading HTML element (h1...h6) base on the number and depth of `Level`'s. 87 | * 88 | * @example 89 | * // Rendering a heading component 90 | * a title 91 | * 92 | * // Overriding the value of the heading 93 | * a title 94 | * 95 | * // Passing HTML element props to the component 96 | * a title 97 | * 98 | * // Passing a React ref to the component 99 | * a title 100 | */ 101 | export const Heading = forwardRef( 102 | ({ children, offset, ...props }: HeadingProps, ref: Ref | undefined) => { 103 | const contextLevel = useContext(HeadingsContext); 104 | const proposedLevel = contextLevel + (offset !== undefined ? offset : 0); 105 | const level = getHeadingLevel(proposedLevel); 106 | const HeadingLevel = `h${level}`; 107 | const elementProps = { 108 | "data-testid": "fdz-js-heading", 109 | ...props, 110 | ref, 111 | }; 112 | 113 | return createElement(HeadingLevel, elementProps, children); 114 | }, 115 | ); 116 | 117 | export * from "./useHeadings"; 118 | -------------------------------------------------------------------------------- /test/vitest/use-focus-visible.test.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /* 4 | * The copyright of this file belongs to Feedzai. The file cannot be 5 | * reproduced in whole or in part, stored in a retrieval system, transmitted 6 | * in any form, or by any means electronic, mechanical, or otherwise, without 7 | * the prior permission of the owner. Please refer to the terms of the license 8 | * agreement. 9 | * 10 | * (c) 2022 Feedzai, Rights Reserved. 11 | */ 12 | 13 | /** 14 | * useFocusVisible.tsx 15 | * 16 | * @author João Dias 17 | * @since ```feedzai.next.release``` 18 | */ 19 | import { describe, it, expect, beforeEach } from "vitest"; 20 | import React from "react"; 21 | import "@testing-library/jest-dom"; 22 | import { fireEvent, render, screen } from "@testing-library/react"; 23 | import { useFocusVisible } from "../../src/hooks"; 24 | 25 | function Example({ id }: { id?: string }) { 26 | const { isFocusVisible } = useFocusVisible(); 27 | 28 | return ( 29 |

30 | Text 31 |

32 | ); 33 | } 34 | 35 | /** 36 | * This describes Chrome behaviour only, for other browsers visibilitychange fires after all focus events. 37 | */ 38 | function toggleBrowserTabs() { 39 | // leave tab 40 | const lastActiveElement = document.activeElement; 41 | fireEvent(lastActiveElement as Element, new Event("blur")); 42 | fireEvent(window, new Event("blur")); 43 | 44 | Object.defineProperty(document, "visibilityState", { 45 | value: "hidden", 46 | writable: true, 47 | }); 48 | Object.defineProperty(document, "hidden", { value: true, writable: true }); 49 | 50 | fireEvent(document, new Event("visibilitychange")); 51 | 52 | // return to tab 53 | Object.defineProperty(document, "visibilityState", { 54 | value: "visible", 55 | writable: true, 56 | }); 57 | Object.defineProperty(document, "hidden", { value: false, writable: true }); 58 | 59 | fireEvent(document, new Event("visibilitychange")); 60 | fireEvent(window, new Event("focus", { target: window } as EventInit)); 61 | fireEvent(lastActiveElement as Element, new Event("focus")); 62 | } 63 | 64 | function toggleBrowserWindow() { 65 | fireEvent(window, new Event("blur", { target: window } as EventInit)); 66 | fireEvent(window, new Event("focus", { target: window } as EventInit)); 67 | } 68 | 69 | describe("useFocusVisible", () => { 70 | beforeEach(() => { 71 | fireEvent.focus(document.body); 72 | }); 73 | 74 | it("returns positive isFocusVisible result after toggling browser tabs after keyboard navigation", () => { 75 | render(); 76 | const element = screen.getAllByTestId("fdz-js-element")[0]; 77 | 78 | fireEvent.keyDown(element, { key: "Tab" }); 79 | toggleBrowserTabs(); 80 | 81 | expect(element).toHaveAttribute("data-focus-visible", "true"); 82 | }); 83 | 84 | it("returns positive isFocusVisible result after toggling browser window after keyboard navigation", () => { 85 | render(); 86 | const element = screen.getAllByTestId("fdz-js-element")[0]; 87 | 88 | fireEvent.keyDown(element, { key: "Tab" }); 89 | toggleBrowserWindow(); 90 | 91 | expect(element).toHaveAttribute("data-focus-visible", "true"); 92 | }); 93 | 94 | it("returns negative isFocusVisible result after toggling browser window without prior keyboard navigation", () => { 95 | render(); 96 | const element = screen.getAllByTestId("fdz-js-element")[0]; 97 | 98 | fireEvent.mouseDown(element); 99 | toggleBrowserWindow(); 100 | 101 | expect(element).toHaveAttribute("data-focus-visible", "false"); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /documentation/docs/testing/demos/styles.module.scss: -------------------------------------------------------------------------------- 1 | .layout { 2 | background-color: white; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, 4 | "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 5 | color: black; 6 | } 7 | 8 | .menu { 9 | height: 100%; 10 | width: 200px; 11 | background-color: #fff; 12 | position: fixed; 13 | z-index: 1; 14 | overflow: auto; 15 | } 16 | 17 | .button { 18 | background-color: #1a227d; 19 | color: white; 20 | padding: 8px 16px; 21 | font-size: 14px; 22 | display: grid; 23 | place-items: center; 24 | appearance: none; 25 | border: none; 26 | border-radius: 48px; 27 | outline: none; 28 | padding-top: 16px; 29 | padding-bottom: 16px; 30 | float: right; 31 | } 32 | 33 | .header { 34 | top: 0; 35 | 36 | &__container { 37 | font-size: 24px; 38 | color: #000; 39 | background-color: #fff; 40 | max-width: 1200px; 41 | margin: auto; 42 | display: flex; 43 | flex-direction: row; 44 | justify-content: space-between; 45 | align-items: center; 46 | } 47 | 48 | &__menu { 49 | display: inline-block; 50 | padding: 8px 16px; 51 | vertical-align: middle; 52 | text-align: center; 53 | cursor: pointer; 54 | white-space: nowrap; 55 | user-select: none; 56 | padding-top: 16px; 57 | padding-bottom: 16px; 58 | float: left; 59 | } 60 | 61 | &__title { 62 | padding-top: 16px; 63 | padding-bottom: 16px; 64 | } 65 | } 66 | 67 | .header { 68 | position: sticky; 69 | width: 100%; 70 | z-index: 1; 71 | } 72 | 73 | .main { 74 | font-size: 15px; 75 | line-height: 1.5; 76 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, 77 | "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 78 | box-sizing: inherit; 79 | margin-left: auto; 80 | margin-right: auto; 81 | padding: 8px 16px; 82 | max-width: 1200px; 83 | margin-top: 100px; 84 | 85 | &__row { 86 | -webkit-text-size-adjust: 100%; 87 | font-size: 15px; 88 | box-sizing: inherit; 89 | padding: 0 8px; 90 | text-align: center; 91 | padding-top: 16px; 92 | padding-bottom: 16px; 93 | 94 | &::before, 95 | &::after { 96 | content: ""; 97 | display: table; 98 | clear: both; 99 | } 100 | } 101 | 102 | &__item { 103 | text-align: center; 104 | box-sizing: inherit; 105 | float: left; 106 | width: 24.99999%; 107 | padding: 0 8px; 108 | 109 | &__title { 110 | font-size: 24px; 111 | font-weight: 400; 112 | margin: 10px 0; 113 | } 114 | 115 | &__description { 116 | text-align: center; 117 | box-sizing: inherit; 118 | } 119 | } 120 | } 121 | 122 | .divider { 123 | border: 0; 124 | border-top: 1px solid #eee; 125 | margin: 20px 0; 126 | } 127 | 128 | .pagination { 129 | text-align: center; 130 | padding-top: 32px; 131 | padding-bottom: 32px; 132 | 133 | &__item { 134 | text-align: center; 135 | cursor: pointer; 136 | user-select: none; 137 | padding: 8px 16px; 138 | float: left; 139 | width: auto; 140 | border: none; 141 | display: block; 142 | outline: 0; 143 | white-space: normal; 144 | color: black; 145 | 146 | &[aria-current="true"] { 147 | color: #fff; 148 | background-color: #000; 149 | } 150 | } 151 | } 152 | 153 | .header, 154 | .main, 155 | .main__row, 156 | .pagination, 157 | .article, 158 | .footer__section { 159 | &::before, 160 | &::after { 161 | content: ""; 162 | display: table; 163 | clear: both; 164 | } 165 | } 166 | 167 | .image { 168 | aspect-ratio: auto 800 / 533; 169 | max-width: 100%; 170 | height: auto; 171 | display: block; 172 | margin: auto; 173 | } 174 | 175 | .article { 176 | padding: 32px; 177 | } 178 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [3.0.0](https://github.com/feedzai/react-a11y-tools/compare/v2.0.1...v3.0.0) (2024-07-15) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **FocusManager:** Replaces implementation with focus-trap-react ([#28](https://github.com/feedzai/react-a11y-tools/issues/28)) ([19bc86c](https://github.com/feedzai/react-a11y-tools/commit/19bc86c03b120aa2bfedb1af5ab653fbd71c1cfb)), closes [#27](https://github.com/feedzai/react-a11y-tools/issues/27) 7 | 8 | 9 | ### BREAKING CHANGES 10 | 11 | * **FocusManager:** autoFocus, restoreFocus and contain are now set to true by default 12 | * **FocusManager:** the `useFocusManager` was reimplemented. Since we no longer use a React context state management solution to travel between elements, the hook was re-implemented as an optional way to facilitate the creation of a `FocusTrap` instance, but without using the provided element by the package. 13 | 14 | ## [2.0.1](https://github.com/feedzai/react-a11y-tools/compare/v2.0.0...v2.0.1) (2024-07-12) 15 | 16 | 17 | ### Bug Fixes 18 | 19 | * **deps:** added @feedzai/js-utilities as peer dependency ([dc58a51](https://github.com/feedzai/react-a11y-tools/commit/dc58a517411901c5748996d912cab4fe34900e4c)) 20 | * **deps:** upgraded @feedzai/js-utilities to 1.4.2 and replaced imports ([121a5e8](https://github.com/feedzai/react-a11y-tools/commit/121a5e8b32451b6d0b7c3a5e858120a159da3d39)) 21 | * **docs:** fixed Rover Provider link ([da59716](https://github.com/feedzai/react-a11y-tools/commit/da597161c8a60d42a29ad37c95b259bad100a619)) 22 | 23 | # [2.0.0](https://github.com/feedzai/react-a11y-tools/compare/v1.5.2...v2.0.0) (2024-04-21) 24 | 25 | 26 | ### Features 27 | 28 | * **bundle:** added dep to replace internal helpers and hooks ([e0fec1d](https://github.com/feedzai/react-a11y-tools/commit/e0fec1ddd6094a187ac70e3f02f1992e0f20c0bd)) 29 | * **SkipLink:** added styles as scss modules ([720d8ed](https://github.com/feedzai/react-a11y-tools/commit/720d8ed56eca067662b096762accd11f39dcf535)) 30 | 31 | 32 | ### BREAKING CHANGES 33 | 34 | * **bundle:** Removed internal hooks (useAutoId, useMergedRefs, usePrevious and useSafeLayoutEffect) as exported modules 35 | * **bundle:** Removed internal hooks (callIfExists, cloneValidElement, emptyFunction, inRange, isBoolean, isBrowser, isFunction, isNil, isNumber, isString, keycodes, classNames, makeId) as exported modules 36 | * **bundle:** Removed "react/jsx-runtime" as a bundled dependency 37 | 38 | ## [1.5.2](https://github.com/feedzai/react-a11y-tools/compare/v1.5.1...v1.5.2) (2024-03-15) 39 | 40 | 41 | ### Bug Fixes 42 | 43 | * **package.json:** fixed wrong exports file extensions ([a2838c7](https://github.com/feedzai/react-a11y-tools/commit/a2838c787798a59bd0741e47d503fd55476ed184)) 44 | 45 | ## [1.5.1](https://github.com/feedzai/react-a11y-tools/compare/v1.5.0...v1.5.1) (2024-03-15) 46 | 47 | 48 | ### Bug Fixes 49 | 50 | * **package.json:** removed postinstall script that ([4a98f56](https://github.com/feedzai/react-a11y-tools/commit/4a98f56d67bfa9edf0d062174e41eea86898bd8f)) 51 | 52 | # [1.5.0](https://github.com/feedzai/react-a11y-tools/compare/v1.4.1...v1.5.0) (2024-03-04) 53 | 54 | 55 | ### Bug Fixes 56 | 57 | * **bundle:** changed bundle from exporting CommonJS to UMD ([e112162](https://github.com/feedzai/react-a11y-tools/commit/e11216274997e92cefc503fe4467d5bd0b989d22)) 58 | * **eslint:** upgraded [@typescript-eslint](https://github.com/typescript-eslint) to version 7 ([df35f88](https://github.com/feedzai/react-a11y-tools/commit/df35f882f14784424134746e3561b5a82ce88b5f)) 59 | 60 | 61 | ### Features 62 | 63 | * replaced merge-coverage script with @jtmdias/merge-coverage ([5fe7f92](https://github.com/feedzai/react-a11y-tools/commit/5fe7f92e1006b219b4fed4ed21a0c5e1ec221217)) 64 | * **unit-tests:** replaced jest with vitest ([250848b](https://github.com/feedzai/react-a11y-tools/commit/250848b0836c0d9c2d220d4ed66686affcac673a)) 65 | -------------------------------------------------------------------------------- /documentation/docs/content-and-navigation/demos/styles.module.scss: -------------------------------------------------------------------------------- 1 | .skipLinks { 2 | &__page { 3 | width: auto; 4 | padding: 2rem; 5 | display: flex; 6 | flex-direction: column; 7 | justify-content: flex-start; 8 | position: relative; 9 | font-family: monospace; 10 | background-color: #e7b9ff; 11 | color: #000a12; 12 | scroll-behaviour: smooth; 13 | 14 | @media all and (min-width: 40rem) { 15 | padding: 2rem 4rem; 16 | } 17 | } 18 | 19 | &__target { 20 | display: flex; 21 | flex-direction: column; 22 | &:focus { 23 | scroll-margin-top: 72px; 24 | outline: 2px dotted #263238; 25 | } 26 | } 27 | 28 | &__container { 29 | max-width: 60rem; 30 | margin: 0 auto; 31 | } 32 | 33 | &__header { 34 | margin-top: 1rem; 35 | margin-bottom: 2rem; 36 | width: 100%; 37 | display: flex; 38 | flex-direction: column; 39 | justify-content: center; 40 | align-items: center; 41 | @media all and (min-width: 40rem) { 42 | flex-direction: row; 43 | justify-content: space-between; 44 | } 45 | } 46 | 47 | &__list { 48 | display: flex; 49 | flex-direction: row; 50 | justify-content: flex-end; 51 | align-items: center; 52 | gap: 1rem; 53 | margin: 0; 54 | padding: 0; 55 | list-style-type: none; 56 | } 57 | 58 | &__anchor { 59 | color: #000a12; 60 | &:focus { 61 | outline: 1px solid currentColor; 62 | outline-offset: 4px; 63 | } 64 | } 65 | 66 | &__paragraph { 67 | column-count: 1; 68 | column-gap: 2rem; 69 | margin-bottom: 4rem; 70 | font-size: 16px; 71 | line-height: 1.618; 72 | @media all and (min-width: 40rem) { 73 | column-count: 2; 74 | font-size: 18px; 75 | } 76 | } 77 | 78 | &__first-letter { 79 | float: left; 80 | font-size: 56px; 81 | line-height: 40px; 82 | padding-top: 4px; 83 | padding-right: 8px; 84 | padding-left: 3px; 85 | @media all and (min-width: 40rem) { 86 | line-height: 50px; 87 | } 88 | } 89 | 90 | &__main-title { 91 | font-size: clamp(28px, 11vw, 64px); 92 | } 93 | 94 | &__button { 95 | appearance: none; 96 | padding: 0.5rem 1rem; 97 | background: hsl(265, 100%, 47%); 98 | border: none; 99 | color: white; 100 | cursor: pointer; 101 | margin-bottom: 1rem; 102 | &:focus { 103 | outline: 1px solid white; 104 | outline-offset: 4px; 105 | } 106 | } 107 | } 108 | 109 | .page { 110 | width: auto; 111 | padding: 2rem; 112 | display: flex; 113 | flex-direction: column; 114 | justify-content: flex-start; 115 | font-family: sans-serif; 116 | background-color: white; 117 | color: #000a12; 118 | h2, 119 | h3 { 120 | font-family: sans-serif; 121 | font-weight: 700; 122 | margin-bottom: 1rem; 123 | line-height: 1.1; 124 | } 125 | h2 { 126 | font-size: 4rem; 127 | } 128 | h3 { 129 | font-size: 2.5rem; 130 | } 131 | h4 { 132 | font-size: 1.25rem; 133 | font-family: serif; 134 | margin-bottom: 0.25rem; 135 | } 136 | 137 | &__image { 138 | max-width: 100%; 139 | height: auto; 140 | display: block; 141 | margin: 0 auto; 142 | margin-bottom: 1.5rem; 143 | } 144 | 145 | &__post { 146 | font-weight: 400; 147 | font-family: Merriweather, serif; 148 | font-size: 1.2rem; 149 | line-height: 1.8; 150 | color: rgba(0, 0, 0, 0.8); 151 | p { 152 | font-weight: 400; 153 | font-family: Merriweather, serif; 154 | font-size: 1.2rem; 155 | line-height: 1.8; 156 | color: rgba(0, 0, 0, 0.8); 157 | margin: 0 0 1.5rem 0; 158 | } 159 | } 160 | 161 | &__quote { 162 | font-weight: 400; 163 | font-family: Merriweather, sans-serif; 164 | font-size: 1.2rem; 165 | line-height: 1.8; 166 | box-sizing: inherit; 167 | border-left: 4px solid #00ab6b; 168 | padding: 0 20px; 169 | font-style: italic; 170 | color: rgba(0, 0, 0, 0.5); 171 | margin: 0 0 1.5rem 0; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/hooks/useTabbable.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license agreement. 3 | * 4 | * (c) 2021 Feedzai, Rights Reserved. 5 | */ 6 | 7 | /** 8 | * useTabbable.ts 9 | * 10 | * An abstract hook that makes elements perceivable for keyboard users. 11 | * 12 | * @author João Dias 13 | * @since 1.0.0 14 | */ 15 | import { useMemo, HTMLAttributes } from "react"; 16 | import { useDisableEvent, UseDisableEventReturns } from "./useDisableEvent"; 17 | 18 | type HTMLElementsSupportDisable = 19 | | HTMLButtonElement 20 | | HTMLInputElement 21 | | HTMLTextAreaElement 22 | | HTMLSelectElement; 23 | type TabbableComponent = Props & { 24 | /** 25 | * Same as the HTML attribute. 26 | */ 27 | disabled?: boolean; 28 | 29 | /** 30 | * When an element is `disabled`, it may still be `focusable`. It works 31 | * similarly to `readOnly` on form elements. In this case, only 32 | * `aria-disabled` will be set. 33 | * 34 | * 35 | * @type {boolean} 36 | * @memberof HTMLTabbableElement 37 | */ 38 | focusable?: boolean; 39 | }; 40 | export type HTMLTabbableElement = HTMLAttributes & 41 | TabbableComponent; 42 | interface TabRelatedAttributes { 43 | disabled?: boolean; 44 | "aria-disabled"?: HTMLAttributes["aria-disabled"]; 45 | } 46 | type UseTabbableHTMLProps = HTMLTabbableElement & 47 | TabRelatedAttributes & { 48 | onClickCapture: UseDisableEventReturns; 49 | onMouseDownCapture: UseDisableEventReturns; 50 | onKeyPressCapture: UseDisableEventReturns; 51 | }; 52 | 53 | /** 54 | * Defines the disabled state of an HTML element. 55 | * 56 | * Its heuristics are: 57 | * 58 | * Given that a button should be focusable with the keyboard: 59 | * - When the `disabled` and the `focusable` props are both `true`, then the `disabled` attribute will remain `undefined` 60 | * and the `aria-disabled` prop wil be `true` instead; 61 | * - When the `disabled` prop is `true` and the `focusable` prop is `false`, then only the `disabled` attribute will be rendered onto the HTML. 62 | * 63 | * This way an assistive technology can still access the contents of an HTML element button without allowing the user to trigger any unintended 64 | * actions, such as activating a button or typing on a text input. 65 | * 66 | * @param {boolean} disabled 67 | * @param {boolean} [focusable] 68 | * @returns {TabRelatedAttributes} disabled attributes that make a DOM element either disabled or enabled. 69 | */ 70 | function getDisabledState(disabled?: boolean, focusable?: boolean): TabRelatedAttributes { 71 | const isFocusableAndDisabled = focusable && disabled; 72 | const isNativelyDisabled = !focusable && disabled; 73 | 74 | switch (true) { 75 | case isNativelyDisabled: 76 | return { 77 | disabled: true, 78 | "aria-disabled": undefined, 79 | }; 80 | 81 | case isFocusableAndDisabled: 82 | return { 83 | "aria-disabled": true, 84 | disabled: undefined, 85 | }; 86 | 87 | case !disabled: 88 | default: 89 | return { 90 | "aria-disabled": undefined, 91 | disabled: false, 92 | }; 93 | } 94 | } 95 | 96 | /** 97 | * An abstract hook that makes elements perceivable for keyboard users. 98 | * If the element is disabled, then it also disables any mouse or keyboard events to bubble up. 99 | * 100 | * @export 101 | * @param {HTMLTabbableElement} htmlProps 102 | * @returns {UseTabbableHTMLProps} 103 | */ 104 | export function useTabbable( 105 | htmlProps: HTMLTabbableElement, 106 | ): UseTabbableHTMLProps { 107 | const disabledState = useMemo( 108 | () => getDisabledState(htmlProps.disabled, htmlProps.focusable), 109 | [htmlProps.disabled, htmlProps.focusable], 110 | ); 111 | const onClickCapture = useDisableEvent(htmlProps.disabled); 112 | const onMouseDownCapture = useDisableEvent(htmlProps.disabled); 113 | const onKeyPressCapture = useDisableEvent(htmlProps.disabled); 114 | 115 | return { 116 | ...htmlProps, 117 | ...disabledState, 118 | onClickCapture, 119 | onMouseDownCapture, 120 | onKeyPressCapture, 121 | }; 122 | } 123 | -------------------------------------------------------------------------------- /src/components/announcer/route-announcer/index.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | 8 | /** 9 | * index.tsx 10 | * 11 | * @author João Dias 12 | * @since 1.0.0 13 | */ 14 | import React, { FunctionComponent, useCallback, useEffect, useState } from "react"; 15 | import { usePrevious } from "@feedzai/js-utilities/hooks"; 16 | import { Announcer } from "../announcer"; 17 | 18 | export interface IRouteAnnouncerActions { 19 | /** 20 | * The action predicate. 21 | * @default "Navigated To" 22 | */ 23 | navigation: string; 24 | 25 | /** 26 | * The fallback predicate for actions. 27 | */ 28 | fallback: string; 29 | } 30 | export interface IRouteAnnouncerProps { 31 | /** 32 | * An optional id for the `focus wrapper` HTML element. 33 | */ 34 | id?: string; 35 | 36 | /** 37 | * History-based Location pathnme 38 | */ 39 | pathname?: string; 40 | 41 | /** 42 | * Content to be read by the screen-reader on the `aria-live` announcer 43 | */ 44 | action?: IRouteAnnouncerActions; 45 | 46 | /** 47 | * Any type of React children inside the RouteAnnouncer 48 | */ 49 | children: React.ReactNode; 50 | } 51 | 52 | const DEFAULT_WRAPPER_ID = "content-focus-wrapper"; 53 | 54 | export const defaultProps: Partial = { 55 | id: DEFAULT_WRAPPER_ID, 56 | action: { 57 | navigation: "Navigated to", 58 | fallback: "new page at", 59 | }, 60 | }; 61 | 62 | /** 63 | * Queries the HTML Head to find the `title` tag 64 | * 65 | * @returns {boolean} 66 | */ 67 | export function hasDocumentTitle(): boolean { 68 | return !!document.title; 69 | } 70 | 71 | /** 72 | * Queries the DOM in order to find the first h1. 73 | * 74 | * @param {string} id 75 | * @returns {string | undefined} 76 | */ 77 | export function getHeadingText(id: string): string | undefined { 78 | const element: HTMLHeadingElement | null = document.querySelector(`#${id} h1`); 79 | 80 | return element && element.textContent ? element.textContent : undefined; 81 | } 82 | 83 | /** 84 | * The `RouteAnnouncer` is a wrapper for the app's content, 85 | * as well as the `Announcer` component. 86 | * 87 | * It listens for a change in the `pathname` prop, so that it passes a new text 88 | * for the `Announcer` to read. 89 | * 90 | * @example 91 | * // Passing a new pathname as prop 92 | * 93 | * 94 | * // The screen reader outputs: 95 | * "Navigated to Create Account" 96 | */ 97 | export const RouteAnnouncer: FunctionComponent = ({ 98 | id = defaultProps.id, 99 | pathname, 100 | action = defaultProps.action, 101 | children, 102 | }) => { 103 | const [text, setText] = useState(""); 104 | const previousPathname = usePrevious(pathname); 105 | 106 | /** 107 | * Creates the string to be read by the `Announcer` component 108 | * It depends on the existance of: 109 | * 1. The location pathname. 110 | * 2. A title in the document head. 111 | * 3. The first h1 it finds on the DOM. 112 | * 113 | * Updates the state with whatever comes last. 114 | * 115 | * @returns {void} 116 | */ 117 | const setAnnouncerText = useCallback(() => { 118 | const hasTitle = hasDocumentTitle(); 119 | /* istanbul ignore next */ 120 | const firstHeading = getHeadingText(id || DEFAULT_WRAPPER_ID); 121 | 122 | let pageName = `${(action as IRouteAnnouncerActions).fallback} ${pathname}`; 123 | 124 | if (hasTitle) { 125 | pageName = document.title; 126 | } 127 | 128 | if (typeof firstHeading === "string" && firstHeading.length > 1) { 129 | pageName = firstHeading; 130 | } 131 | 132 | const newMessage = `${(action as IRouteAnnouncerActions).navigation} ${pageName}`; 133 | 134 | setText(newMessage); 135 | }, [action, id, pathname]); 136 | 137 | useEffect(() => { 138 | if (previousPathname && previousPathname !== pathname) { 139 | setAnnouncerText(); 140 | } 141 | }, [pathname, previousPathname, setAnnouncerText]); 142 | 143 | return ( 144 |
151 | {children} 152 | 153 |
154 | ); 155 | }; 156 | -------------------------------------------------------------------------------- /documentation/docs/feedback/demos/messages-announcer/styles.module.scss: -------------------------------------------------------------------------------- 1 | .page-wrapper { 2 | background: #ddeefc; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, 4 | "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 5 | font-size: 16px; 6 | 7 | :global(*) { 8 | box-sizing: border-box; 9 | } 10 | } 11 | 12 | .card { 13 | max-width: 620px; 14 | min-width: 490px; 15 | margin: 0 auto; 16 | padding-top: 50px; 17 | padding-bottom: 70px; 18 | width: 100%; 19 | } 20 | 21 | .card__inputs { 22 | background: #fff; 23 | box-shadow: 0 30px 60px 0 rgba(90, 116, 148, 0.4); 24 | border-radius: 10px; 25 | } 26 | 27 | .card__form { 28 | margin: 0 auto; 29 | padding: 40px; 30 | 31 | :global(fieldset) { 32 | border: none; 33 | display: grid; 34 | grid-gap: 1rem; 35 | grid-template-columns: 1fr 2fr 1fr; 36 | width: 100%; 37 | margin: 0 auto; 38 | } 39 | :global(legend) { 40 | font-size: 24px; 41 | font-weight: bold; 42 | width: 100%; 43 | grid-column: 1/4; 44 | color: black; 45 | } 46 | :global(.lg-input) { 47 | grid-column: 1 / 4; 48 | } 49 | :global(.med-input) { 50 | grid-column: 1 / 3; 51 | } 52 | :global(.sm-input) { 53 | grid-column: 3 / 4; 54 | } 55 | :global(label) { 56 | display: block; 57 | font-size: 14px; 58 | margin-bottom: 5px; 59 | font-weight: 500; 60 | color: #1a3b5d; 61 | width: 100%; 62 | display: block; 63 | user-select: none; 64 | position: relative; 65 | &:after { 66 | content: ""; 67 | position: absolute; 68 | top: 0; 69 | right: 0.5rem; 70 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='%2333691E' d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z'/%3E%3C/svg%3E"); 71 | background-size: 1rem 1rem; 72 | width: 1rem; 73 | height: 1rem; 74 | } 75 | } 76 | :global(.name-input), 77 | :global(.number-input), 78 | :global(.cvv-input) { 79 | width: 100%; 80 | } 81 | :global(.month-input), 82 | :global(.year-input) { 83 | width: 44%; 84 | } 85 | :global(.month-input) { 86 | margin-right: 1rem; 87 | } 88 | :global(.year-input) { 89 | margin-left: 1rem; 90 | } 91 | :global(input), 92 | :global(select) { 93 | height: 50px; 94 | border-radius: 5px; 95 | border: 1px solid #ced6e0; 96 | box-shadow: none; 97 | font-size: 18px; 98 | padding: 5px 16px; 99 | background: none; 100 | color: #1a3b5d; 101 | font-family: inherit; 102 | transition: all 0.3s ease-in-out; 103 | letter-spacing: 1px; 104 | &:hover, 105 | &:focus { 106 | border-color: #3d9cff; 107 | } 108 | &:focus { 109 | box-shadow: 0px 10px 20px -13px rgba(32, 56, 117, 0.35); 110 | } 111 | } 112 | :global(select) { 113 | appearance: none; 114 | background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIAAAAeCAYAAABuUU38AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAUxJREFUeNrM1sEJwkAQBdCsngXPHsQO9O5FS7AAMVYgdqAd2IGCDWgFnryLFQiCZ8EGnJUNimiyM/tnk4HNEAg/8y6ZmMRVqz9eUJvRaSbvutCZ347bXVJy/ZnvTmdJ862Me+hAbZCTs6GHpyUi1tTSvPnqTpoWZPUa7W7ncT3vK4h4zVejy8QzM3WhVUO8ykI6jOxoGA4ig3BLHcNFSCGqGAkig2yqgpEiMsjSfY9LxYQg7L6r0X6wS29YJiYQYecemY+wHrXD1+bklGhpAhBDeu/JfIVGxaAQ9sb8CI+CQSJ+QmJg0Ii/EE2MBiIXooHRQhRCkBhNhBcEhLkwf05ZCG8ICCOpk0MULmvDSY2M8UawIRExLIQIEgHDRoghihgRIgiigBEjgiFATBACAgFgghEwSAAGgoBCBBgYAg5hYKAIFYgHBo6w9RRgAFfy160QuV8NAAAAAElFTkSuQmCC"); 115 | background-size: 12px; 116 | background-position: 90% center; 117 | background-repeat: no-repeat; 118 | padding-right: 30px; 119 | } 120 | :global(button) { 121 | height: 55px; 122 | background: #2364d2; 123 | border: none; 124 | border-radius: 5px; 125 | font-size: 22px; 126 | font-weight: 500; 127 | font-family: inherit; 128 | box-shadow: 3px 10px 20px 0px rgba(35, 100, 210, 0.3); 129 | color: #fff; 130 | margin-top: 20px; 131 | cursor: pointer; 132 | transition: all 0.3s ease-in-out; 133 | } 134 | } 135 | 136 | .error-message { 137 | width: 100%; 138 | background-color: hsla(134deg 57% 45% / 28%); 139 | border: 2px solid hsl(134deg 54% 34%); 140 | color: #0a1818; 141 | margin: 2rem auto 0 auto; 142 | border-radius: 5px; 143 | padding: 0.5rem 1.5rem; 144 | text-align: center; 145 | } 146 | -------------------------------------------------------------------------------- /cypress/e2e/announcer.cy.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | 8 | /** 9 | * announcer.spec.js 10 | * 11 | * @author João Dias 12 | * @since 1.0.0 13 | */ 14 | const ROUTE_STORY_URL = "/docs/feedback/route-announcer#/"; 15 | const MESSAGES_STORY_URL = "/docs/feedback/messages-announcer"; 16 | 17 | describe("Announcer", () => { 18 | describe("Route Announcer", () => { 19 | beforeEach(() => { 20 | cy.visit(ROUTE_STORY_URL); 21 | cy.findByTestId("fdz-js-docs-browser-window").as("preview"); 22 | cy.get("#__docusaurus article .markdown h1").as("pageLeveOneHeading"); 23 | cy.get("@preview").find("h1").as("previewLevelOneHeading"); 24 | 25 | cy.get("nav [href='#/']").as("BlogLink"); 26 | cy.get("nav [href='#/article-1']").as("ArticleOneLink"); 27 | cy.get("nav [href='#/article-2']").as("ArticleTwoLink"); 28 | cy.get("nav [href='#/product']").as("ProductLink"); 29 | cy.findByTestId("fdz-js-announcer").as("announcer"); 30 | }); 31 | 32 | it("should be empty by default", () => { 33 | cy.get("@preview").within(() => { 34 | cy.get("@announcer").should("not.have.text"); 35 | }); 36 | }); 37 | 38 | describe("change routes and announce them", () => { 39 | beforeEach(() => { 40 | cy.get("@BlogLink").click(); 41 | }); 42 | 43 | it("By their level-1 heading", () => { 44 | cy.get("@preview").within(() => { 45 | cy.tabUntil(() => cy.get("@ArticleTwoLink")).click(); 46 | 47 | cy.get("@previewLevelOneHeading") 48 | .then(($heading) => { 49 | const articlePageHeading = $heading.text(); 50 | cy.get("@announcer").should("have.text", `Navigated to ${articlePageHeading}`); 51 | cy.get("@ArticleTwoLink").should("have.attr", "aria-current", "page"); 52 | cy.tabUntil(() => cy.findByTestId("fdz-js-route-announer-go-back")).click(); 53 | 54 | return cy.get("@previewLevelOneHeading"); 55 | }) 56 | .then(($heading) => { 57 | const listPageHeading = $heading.text(); 58 | cy.get("@announcer").should("have.text", `Navigated to ${listPageHeading}`); 59 | cy.get("@BlogLink").should("have.attr", "aria-current", "page"); 60 | }); 61 | }); 62 | }); 63 | 64 | it("By their document title", () => { 65 | cy.get("@preview").within(() => { 66 | cy.tabUntil(() => cy.get("@ProductLink")).click(); 67 | 68 | cy.title() 69 | .then(($heading) => { 70 | const productPageTitle = $heading; 71 | cy.get("@announcer").should("have.text", `Navigated to ${productPageTitle}`); 72 | cy.get("@ProductLink").should("have.attr", "aria-current", "page"); 73 | 74 | cy.tabUntil(() => cy.get("@BlogLink"), true).click(); 75 | 76 | return cy.get("@previewLevelOneHeading"); 77 | }) 78 | .then(($heading) => { 79 | const listPageHeading = $heading.text(); 80 | cy.get("@announcer").should("have.text", `Navigated to ${listPageHeading}`); 81 | cy.get("@BlogLink").should("have.attr", "aria-current", "page"); 82 | }); 83 | }); 84 | }); 85 | 86 | it("By their url", () => { 87 | cy.get("@preview").within(() => { 88 | cy.tabUntil(() => cy.findByTestId("fdz-js-route-announcer-card-link")).click(); 89 | 90 | cy.location().then(($location) => { 91 | const locationLink = $location.hash.substring(1); 92 | const title = `Navigated to new page at ${locationLink}`; 93 | cy.get("@announcer").should("have.text", title); 94 | }); 95 | }); 96 | }); 97 | }); 98 | }); 99 | 100 | describe("Message Announcer", () => { 101 | beforeEach(() => { 102 | cy.visit(MESSAGES_STORY_URL); 103 | cy.findByTestId("fdz-js-docs-browser-window").as("preview"); 104 | 105 | cy.findByRole("button", { name: "Pay 9.99€" }).as("Submit"); 106 | cy.findByTestId("fdz-js-announcer").as("announcer"); 107 | }); 108 | 109 | it("should be empty by default", () => { 110 | cy.get("@preview").within(() => { 111 | cy.get("@announcer").should("not.have.text"); 112 | }); 113 | }); 114 | 115 | it("should announce a change after submission", () => { 116 | cy.get("@preview").within(() => { 117 | cy.get("@Submit").click(); 118 | cy.get("@announcer").should("have.text", "The money it's on the way! Thank you!"); 119 | }); 120 | }); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /documentation/docs/manage-focus/rover-provider.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | title: RoverProvider 4 | --- 5 | 6 | import { DemoRovingTabIndex } from "./demos"; 7 | import { Subtitle, BrowserWindow, PropsTable } from "../../src/components/index"; 8 | 9 | An accessibility pattern for a grouped set of inputs 10 | 11 | 12 | 13 | 14 |
15 | 16 | :::note How to 17 | 18 | - Use the tab key to navigate to the Menu. 19 | - Then, use the `ArrowUp` and `ArrowDown` keys to go through each option. 20 | - Try pressing `Home` or `End` to jump right to the first or last elements on the group. 21 | - Press `tab` (or `shift+tab`) again to exit. 22 | ::: 23 | 24 | ### Development Instructions 25 | 26 | #### 1. Wrap each roving tabindex group in a `RoverProvider` 27 | 28 | You can nest roving tabindex components in other DOM elements or React components. 29 | 30 | ```jsx 31 | 32 | {..content here} 33 | 34 | ``` 35 | 36 | You can also choose the direction of the navigation. 37 | It can either be "vertical" (default) or "horizontal". 38 | 39 | ```jsx 40 | 41 | {..content here} 42 | 43 | ``` 44 | 45 | Choosing between "vertical" and "horizontal" implies you can use: 46 | 47 | - `horizontal` - "ArrowLeft" and "ArrowRight" keys 48 | - `vertical` - "ArrowUp" and "ArrowDown" 49 | - `Home` key to go to the first element 50 | - `End` key to go to the last element 51 | 52 | #### 2. Wrap each focusable element 53 | 54 | For composition, try to identify which elements are the ones that are going to be affected by the `RoverProvider`. 55 | For each one of those, wrap them with your own component and use the `useRover` and `useFocus` hooks. 56 | 57 | ```jsx 58 | const MenuButton = ({ disabled = false, children }) => { 59 | const buttonRef = useRef(null); 60 | 61 | const [tabIndex, focused, handleKeyDown, handleClick] = useRover(buttonRef, disabled); 62 | 63 | useFocus(focused, buttonRef); 64 | 65 | function onKeyDown(event) { 66 | handleKeyDown(event); 67 | yourOwnFunctionToDoWhatever(event); 68 | } 69 | 70 | function onClick(event) { 71 | handleClick(event); 72 | yourOwnFunctionToDoWhatever(event); 73 | } 74 | 75 | return ( 76 | 86 | ); 87 | }; 88 | ``` 89 | 90 | #### 3. Place them inside your `RoverProvider` structure 91 | 92 | Since `RovingTabIndex` relies on React Context, there's no need to have the buttons all as direct children of the provider. 93 | You can nest as deep as you'd like and it will work as well 🙂 94 | 95 | ```jsx 96 | 97 | First Button 98 | Second Button 99 |
    100 |
  • Another Button 101 |
  • Another Button 102 |
  • Another Button 103 |
  • Another Button 104 |
105 |
106 | ``` 107 | 108 | ### Differences with `Focus Manager` 109 | 110 | Both are made to deal with focus and navigation but do it in different ways and for different scenarios: 111 | 112 | 1. The `FocusManager` can, if necessary, scope the focus inside a group and allows the user to press the `tab` key to move to the next "tabbable" element (or `shift+tab` to move to the previous). 113 | 114 | 💡 Tip: Use it for dealing with interface elements that require focus control, like popovers, modals and side menus. 115 | 116 |
117 | 118 | 2. The `RoverProvider` can also manage focus inside a group but, instead of using the `tab` key to move 119 | back and forth between elements, it uses navigation keys (`ArrowUp`, `ArrowDown`, `ArrowLeft` or `ArrowRight`) 120 | to do that. 121 | 122 | It also makes the other group's elements unable to receive focus using the `tab` key since it applies the negative `tabindex` to the components that are not currently selected. 123 | 124 | 💡 Tip: Use it for dealing with interface elements that require selection, like a custom select element or a navigation menu. (Still, you should try to use the native HTML elements). 125 | 126 | ### Props 127 | 128 | #### Rover Provider 129 | 130 | 131 | -------------------------------------------------------------------------------- /documentation/docs/feedback/demos/messages-announcer/index.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Please refer to the terms of the license 3 | * agreement. 4 | * 5 | * (c) 2021 Feedzai, Rights Reserved. 6 | */ 7 | 8 | /** 9 | * index.tsx 10 | * 11 | * @author João Dias 12 | * @since 1.0.0 13 | */ 14 | import React from "react"; 15 | import classNames from "clsx"; 16 | import { MessagesAnnouncer, useMessagesAnnouncer } from "../../../../../src"; 17 | import styles from "./styles.module.scss"; 18 | 19 | const Form = ({ hasSubmitted, onSubmit }: { hasSubmitted: boolean; onSubmit: () => void }) => { 20 | const { setMessage } = useMessagesAnnouncer(); 21 | 22 | return ( 23 |
event.preventDefault()}> 24 |
25 | Credit Card Form 26 |
27 | 28 | 42 |
43 |
44 | 45 | 52 |
53 |
54 | 55 | 72 | 90 |
91 |
92 | 93 | 94 |
95 | 109 |
110 | {hasSubmitted && ( 111 |
112 |

Sent!

113 |
114 | )} 115 |
116 | ); 117 | }; 118 | 119 | export const DemoMessagesAnnouncer = () => { 120 | const [hasSubmitted, setHasSubmitted] = React.useState(false); 121 | 122 | return ( 123 |
124 |
125 |
126 | 127 |
setHasSubmitted(true)} /> 128 | 129 |
130 |
131 |
132 | ); 133 | }; 134 | 135 | export default DemoMessagesAnnouncer; 136 | --------------------------------------------------------------------------------