├── .codesandbox
└── ci.json
├── .github
└── workflows
│ ├── docs.yml
│ ├── main.yml
│ └── size.yml
├── .gitignore
├── .storybook
├── main.js
└── preview.js
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── docs
├── access-user-preferences.mdx
├── advanced
│ ├── beforehoverdefault.png
│ ├── gotchas.mdx
│ ├── hoverdefault.png
│ ├── hoverdefaultbecause.png
│ ├── hoverdefaultout.png
│ ├── hoverfix.png
│ ├── hoverfixbecause.png
│ └── how-it-works.mdx
├── introduction.mdx
├── logo.jpg
├── react-three-a11y.jpg
└── roles
│ ├── button.mdx
│ ├── content.mdx
│ ├── link.mdx
│ └── togglebutton.mdx
├── example
├── .prettierrc
├── index.html
├── package-lock.json
├── package.json
├── public
│ └── index.html
├── src
│ ├── App.js
│ ├── index.js
│ ├── index.tsx
│ └── styles.css
└── yarn.lock
├── package.json
├── src
├── A11y.tsx
├── A11yAnnouncer.tsx
├── A11yConsts.tsx
├── A11yDebuger.tsx
├── A11ySection.tsx
├── A11yUserPreferences.tsx
├── Html.tsx
├── announceStore.tsx
└── index.tsx
├── stories
└── Thing.stories.tsx
├── test
└── blah.test.tsx
├── tsconfig.json
└── yarn.lock
/.codesandbox/ci.json:
--------------------------------------------------------------------------------
1 | {
2 | "sandboxes": ["vecdv"]
3 | }
4 |
--------------------------------------------------------------------------------
/.github/workflows/docs.yml:
--------------------------------------------------------------------------------
1 | name: Build documentation and deploy to GitHub Pages
2 | on:
3 | push:
4 | branches: ['main']
5 | workflow_dispatch:
6 |
7 | # Cancel previous run (see: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#concurrency)
8 | concurrency:
9 | group: ${{ github.workflow }}-${{ github.ref }}
10 | cancel-in-progress: true
11 |
12 | jobs:
13 | build:
14 | uses: pmndrs/docs/.github/workflows/build.yml@v2
15 | with:
16 | mdx: 'docs'
17 | libname: 'A11y'
18 | home_redirect: '/introduction'
19 | icon: '♿️'
20 | logo: '/logo.jpg'
21 | github: 'https://github.com/pmndrs/react-three-a11y'
22 |
23 | deploy:
24 | needs: build
25 | runs-on: ubuntu-latest
26 |
27 | # Grant GITHUB_TOKEN the permissions required to make a Pages deployment
28 | permissions:
29 | pages: write # to deploy to Pages
30 | id-token: write # to verify the deployment originates from an appropriate source
31 |
32 | # Deploy to the github-pages environment
33 | environment:
34 | name: github-pages
35 | url: ${{ steps.deployment.outputs.page_url }}
36 |
37 | steps:
38 | - id: deployment
39 | uses: actions/deploy-pages@v4
40 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on: [push]
3 | jobs:
4 | build:
5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }}
6 |
7 | runs-on: ${{ matrix.os }}
8 | strategy:
9 | matrix:
10 | node: ['10.x', '12.x', '14.x']
11 | os: [ubuntu-latest, windows-latest, macOS-latest]
12 |
13 | steps:
14 | - name: Checkout repo
15 | uses: actions/checkout@v2
16 |
17 | - name: Use Node ${{ matrix.node }}
18 | uses: actions/setup-node@v1
19 | with:
20 | node-version: ${{ matrix.node }}
21 |
22 | - name: Install deps and build (with cache)
23 | uses: bahmutov/npm-install@v1
24 |
25 | - name: Lint
26 | run: yarn lint
27 |
28 | - name: Test
29 | run: yarn test --ci --coverage --maxWorkers=2
30 |
31 | - name: Build
32 | run: yarn build
33 |
--------------------------------------------------------------------------------
/.github/workflows/size.yml:
--------------------------------------------------------------------------------
1 | name: size
2 | on: [pull_request]
3 | jobs:
4 | size:
5 | runs-on: ubuntu-latest
6 | env:
7 | CI_JOB_NUMBER: 1
8 | steps:
9 | - uses: actions/checkout@v1
10 | - uses: andresz1/size-limit-action@v1
11 | with:
12 | github_token: ${{ secrets.GITHUB_TOKEN }}
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.log
2 | .DS_Store
3 | node_modules
4 | .cache
5 | .example/.parcel-cache
6 | dist
7 |
--------------------------------------------------------------------------------
/.storybook/main.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | stories: ['../stories/**/*.stories.@(ts|tsx|js|jsx)'],
3 | addons: ['@storybook/addon-links', '@storybook/addon-essentials'],
4 | // https://storybook.js.org/docs/react/configure/typescript#mainjs-configuration
5 | typescript: {
6 | check: true, // type-check stories during Storybook build
7 | }
8 | };
9 |
--------------------------------------------------------------------------------
/.storybook/preview.js:
--------------------------------------------------------------------------------
1 | // https://storybook.js.org/docs/react/writing-stories/parameters#global-parameters
2 | export const parameters = {
3 | // https://storybook.js.org/docs/react/essentials/actions#automatically-matching-args
4 | actions: { argTypesRegex: '^on.*' },
5 | };
6 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.defaultFormatter": "esbenp.prettier-vscode",
3 | "editor.codeActionsOnSave": {
4 | "source.fixAll.eslint": true
5 | },
6 | "editor.formatOnSave": true,
7 | "[typescriptreact]": {
8 | "editor.defaultFormatter": "esbenp.prettier-vscode"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | <<<<<<< HEAD
4 | Copyright (c) 2021 Gianmarco Simone
5 | =======
6 | Copyright (c) 2021 AlaricBaraou
7 | >>>>>>> master
8 |
9 | Permission is hereby granted, free of charge, to any person obtaining a copy
10 | of this software and associated documentation files (the "Software"), to deal
11 | in the Software without restriction, including without limitation the rights
12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 | copies of the Software, and to permit persons to whom the Software is
14 | furnished to do so, subject to the following conditions:
15 |
16 | The above copyright notice and this permission notice shall be included in all
17 | copies or substantial portions of the Software.
18 |
19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
@react-three/a11y
2 |
3 | [](https://www.npmjs.com/package/@react-three/a11y)
4 | [](https://www.npmjs.com/package/@react-three/a11y)
5 | [](https://discord.gg/ZZjjNvJ)
6 |
7 | 
8 |
9 | @react-three/a11y brings accessibility to webGL with easy-to-use react-three-fiber components:
10 |
11 | - Focus and focus indication
12 | - Tab index and keyboard navigation
13 | - Screen reader support and alt-text
14 | - Roles and cursor shapes
15 | - Descriptive links
16 |
17 | You can try a [live demo here](https://n4rzi.csb.app).
18 |
19 | ```bash
20 | npm install @react-three/a11y
21 | ```
22 |
23 | ## Quick overview to get started
24 |
25 | note: The full documentation can be found on the [pmndrs website](https://docs.pmnd.rs/a11y/introduction).
26 |
27 | ### The A11yAnnouncer component
28 |
29 | First, place the A11yAnnouncer component next to the R3F Canvas component. this componant is critical since it manage some screen-reader features.
30 |
31 | ```jsx
32 | import { Canvas } from '@react-three/fiber';
33 | import { A11yAnnouncer } from '@react-three/a11y';
34 |
35 | function App() {
36 | return (
37 | <>
38 |
39 |
40 | >
41 | );
42 | }
43 | ```
44 |
45 | ### Then wrap components you want to make accessible with the A11y component
46 |
47 | To add accessibility features to your scene you'll have to wrap components you want to make focusable with the `A11y` component:
48 |
49 | ```jsx
50 | import { A11y } from '@react-three/a11y'
51 | [...]
52 |
53 |
54 |
55 | ```
56 |
57 | `MyComponent` can now receive focus. More accurately, the emulated "focus" will be handled at the `A11y` components which acts as a provider
58 | for children to access its state. But even if objects are focusable, nothing will be displayed or shown by default.
59 |
60 | ## Call function on focus
61 |
62 | The `focusCall` prop of `A11y` will be called each time this component receives focus (usually through tab navigation).
63 |
64 | ```jsx
65 | console.log("in focus")} ... />
66 | ```
67 |
68 | ## Call function on click / keyboard Click
69 |
70 | The `actionCall` prop of `A11y` will be called each time this component gets clicked, focused, keyboard activated etc.
71 |
72 | ```jsx
73 | console.log("clicked")} ... />
74 | ```
75 |
76 | ## Provide a description of the currently focused / hovered element
77 |
78 | When using the `description` prop in combination with the `role` prop, the `A11y` component will provide a description to the screen reader users on focus/hover.
79 | Optionally, you can also show the description to the user on hover by setting `showAltText={true}`.
80 |
81 | ```jsx
82 | // Reads "A rotating red square" to screen readers on focus / hover while also showing it on mouseover
83 |
84 | // Reads "Button, open menu + (description on how to activate depending on the screen reader)" to screen readers on focus / hover
85 | {someFunction()}} ... />
86 | ```
87 |
88 | ## The four roles of the A11y component
89 |
90 | Like in HTML, you can focus different kind of elements and expect different things depending on what you're focusing.
91 |
92 | #### Content
93 |
94 | ```jsx
95 |
96 | ```
97 |
98 | Uses the `default` cursor. This role is meant to provide information to screen readers or to serve as a step for a user to navigate your site using Tab for instance. It's not meant to trigger anything on click or to be activable with the Keyboard. Therefore it won't show a pointer cursor on hover.
99 |
100 | [Read more about role content](/a11y/roles/content)
101 |
102 | #### Button
103 |
104 | ```jsx
105 |
109 | ```
110 |
111 | Uses the `pointer` cursor. Special attributes: `activationMsg`.
112 |
113 | This role is meant to emulate the behaviour of a button or a togglable button. It will display a cursor pointer when your cursor is over the linked 3D object. It will call a function on click but also on any kind of action that would trigger a focused button (Enter, Double-Tap, ...). It is also actionnable by user using a screen reader.
114 |
115 | [Read more about role button](https://docs.pmnd.rs/a11y/roles/button)
116 |
117 | #### ToggleButton
118 |
119 | By using the role togglebutton, you'll emulate a button with two state that will have the `aria-pressed` attribute.
120 | You'll then be able to use the deactivationMsg property in addition to the usual description and activationMsg properties.
121 |
122 | ```jsx
123 |
128 | ```
129 |
130 | Special attributes: `deactivationMsg`
131 |
132 | [Read more about role ToggleButton](https://docs.pmnd.rs/a11y/roles/togglebutton)
133 |
134 | #### Link
135 |
136 | ```jsx
137 |
138 | ```
139 |
140 | Uses the `pointer` cursor. Special attributes: `href`.
141 |
142 | This role is meant to emulate the behaviour of a regular html link. It should be used in combination with something that will trigger navigation on click.
143 |
144 | > [!NOTE]
145 | > Don't forget to provide the href attribute as he is required for screen readers to read it correctly ! - It will have no effect on the navigation, it's just used as information
146 |
147 | [Read more about role link](https://docs.pmnd.rs/a11y/roles/link)
148 |
149 | ## Screen Reader Support
150 |
151 | In order to provide informations to screen reader users and use this package at its full potential, fill the `description` prop of all your `A11y` components and use the appropriate `role` prop on each of them.
152 |
153 | ### Use of section
154 |
155 | For screen readears, it might be useful to provide additionnal information on how to use some unconventional UI.
156 | You can do it by wrapping the concerned part of your code relative to this UI in the A11ySection like so.
157 |
158 | ```jsx
159 |
163 | [...]
164 |
165 | ```
166 |
167 | ## Access user preferences
168 |
169 | The A11yUserPreferences component is available in order to access user preferences such as
170 |
171 | - prefers-reduced-motion
172 | - prefers-color-scheme
173 |
174 | Take a look at [the A11yUserPreferences page](https://docs.pmnd.rs/a11y/access-user-preferences) or the [demo](https://n4rzi.csb.app) to see it in action and how to use it. The demo will adapt to your system preferences.
175 |
176 | ## Additionals Features
177 |
178 | Use a custom tabindex with for your A11y components by providing a number to the tabIndex attribute
179 |
180 | ```jsx
181 |
182 | ```
183 |
184 | > [!CAUTION]
185 | > Avoid using `tabindex` values greater than 0. Doing so makes it difficult for people who rely on assistive technology to navigate and operate page content.
186 | > Instead, write the document with the elements in a logical sequence. More about the use of tabIndex on [developer.mozilla.org](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex)
187 |
188 | ### Author:
189 |
190 | - [`Bluesky 👋 @AlaricBaraou`](https://bsky.app/profile/alaricbaraou.bsky.social)
191 |
--------------------------------------------------------------------------------
/docs/access-user-preferences.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: User Preferences
3 | description: Learn how to access user preferences and how to adjust your app using react-three-fiber with @react-three-a11y
4 | nav: 2
5 | ---
6 |
7 | When it comes to accessibility, some users might need to have an interface as still as possible or with a preferred color scheme.
8 |
9 | These are CSS media features and this library expose two of them
10 |
11 | 1. [prefers-color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme)
12 | 1. [prefers-reduced-motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion)
13 |
14 | While you don't necessarily require this library to access them, we provide an easy way to use them and listen to their changes.
15 |
16 | ## Setup
17 |
18 | For that wrap your components inside the A11yUserPreferences component
19 |
20 | ```jsx
21 |
22 |
23 |
24 | [...]
25 |
26 | ```
27 |
28 | Then, you can access the preferences in each children component where you might need them.
29 |
30 | ## Reduce motions / animations for the users that request it
31 |
32 | Some user on your website might need all animation turned off or limited to what's strictly necessary.
33 | For those users, if your app has an animation going somewhere, consider cancelling it for those who request it.
34 | You can do it like so.
35 |
36 | ```jsx
37 | const My3dObject = () => {
38 | // this const will give you access to the user preferences
39 | const { a11yPrefersState } = useUserPreferences()
40 | const mesh = useRef()
41 |
42 | // Rotate mesh every frame
43 | useFrame(() => {
44 | //unless the user prefers reduced motion
45 | if (!a11yPrefersState.prefersReducedMotion) {
46 | mesh.current.rotation.x = mesh.current.rotation.y += 0.01
47 | }
48 | })
49 |
50 | return (
51 |
52 |
53 |
54 |
55 | )
56 | }
57 | ```
58 |
59 | ## Adapt colour scheme depending on the user preference
60 |
61 | Some user on your website might need a darker / lighter theme. You can adapt your components according to it like so.
62 |
63 | ```jsx
64 | const My3dObject = () => {
65 | // this const will give you access to the user preferences
66 | const { a11yPrefersState } = useUserPreferences()
67 | const mesh = useRef()
68 |
69 | return (
70 |
71 |
72 |
73 |
74 | )
75 | }
76 | ```
77 |
78 | ## Use the context outside and inside the r3f canvas
79 |
80 | At the moment React context [can not be readily used between two renderers](https://github.com/pmndrs/react-three-fiber/issues/43), this is due to a problem within React.
81 | If react-dom use the A11yUserPreferences provider, you will not be able to consume it within ``.
82 |
83 | There's a ready-made solution in drei: [useContextBridge](https://github.com/pmndrs/drei#usecontextbridge) which allows you to forward contexts provided above the ` ` to be consumed within it.
84 |
85 | You can see how it's used in the [react-three-a11y demo](https://n4rzi.csb.app)
86 |
--------------------------------------------------------------------------------
/docs/advanced/beforehoverdefault.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pmndrs/react-three-a11y/fc70f2676723ffef85f3a0d7bdef7c463c22e950/docs/advanced/beforehoverdefault.png
--------------------------------------------------------------------------------
/docs/advanced/gotchas.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Gotchas
3 | description: Things that could catch you off guard
4 | nav: 8
5 | ---
6 |
7 | ### Position of the accessible dom causing wrong hover detection
8 |
9 | In the demo, you may have noticed in App.js that the component ToggleButton is wrapped in an A11y component that use the prop `a11yElStyle` like so
10 |
11 | ```jsx
12 | (state.dark = !snap.dark)}
16 | activationMsg="Lower light disabled"
17 | deactivationMsg="Lower light enabled"
18 | a11yElStyle={{ marginLeft: '-40px' }}
19 | >
20 |
21 |
22 | ```
23 |
24 | Why is that ?
25 |
26 | In order to make this "donut" accessible as a button react-three-a11y will keep an html button over it.
27 |
28 | If we inspect the DOM, you should see something roughly like this for the above example.
29 |
30 | ```html
31 | Light intensity
32 | ```
33 |
34 | By default this button is positioned in the center of your 3D object which would cause it to work like this.
35 |
36 | 1- Mouse is not over
37 |
38 |
39 |
40 |
41 |
42 | 2- Mouse is over the donut, color change is triggered and the cursor pointer is displayed
43 |
44 |
45 |
46 |
47 |
48 | 3- Mouse is not over the donut, but the color change is still triggered as is the cursor pointer
49 |
50 |
51 |
52 |
53 |
54 | If we display the button we can see that it's caused by the button being positioned in the middle of the donut
55 |
56 |
57 |
58 |
59 |
60 | 4- If we add `a11yElStyle={{ marginLeft: '-40px' }}` to the A11y component, the button is moved to the left and not in the center of the donut anymore
61 |
62 |
63 |
64 |
65 |
66 | 5- And as we can see, it fixes our issue. The cursor is default and no color change while the cursor is in the hole of the donut.
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/docs/advanced/hoverdefault.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pmndrs/react-three-a11y/fc70f2676723ffef85f3a0d7bdef7c463c22e950/docs/advanced/hoverdefault.png
--------------------------------------------------------------------------------
/docs/advanced/hoverdefaultbecause.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pmndrs/react-three-a11y/fc70f2676723ffef85f3a0d7bdef7c463c22e950/docs/advanced/hoverdefaultbecause.png
--------------------------------------------------------------------------------
/docs/advanced/hoverdefaultout.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pmndrs/react-three-a11y/fc70f2676723ffef85f3a0d7bdef7c463c22e950/docs/advanced/hoverdefaultout.png
--------------------------------------------------------------------------------
/docs/advanced/hoverfix.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pmndrs/react-three-a11y/fc70f2676723ffef85f3a0d7bdef7c463c22e950/docs/advanced/hoverfix.png
--------------------------------------------------------------------------------
/docs/advanced/hoverfixbecause.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pmndrs/react-three-a11y/fc70f2676723ffef85f3a0d7bdef7c463c22e950/docs/advanced/hoverfixbecause.png
--------------------------------------------------------------------------------
/docs/advanced/how-it-works.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: How does it work?
3 | description:
4 | This is an advanced guide on the inner workings of @react-three/a11y, if you are just getting started, take a
5 | look at our introduction!
6 | nav: 7
7 | ---
8 |
9 | This library is pretty simple, if you're curious about how it works you can read the following information and look at the internals.
10 |
11 | @react-three/a11y purpose is to manage the accessibility part of what is inside your canvas by syncing semantic DOM absolutely positioned over what's currently visible in your page.
12 |
13 | Basically when you add the A11y component with a role, react-three-a11y will append the corresponding HTML tag to your document.
14 |
15 | - role="link" => a
16 | - role="button" => button
17 | - role="content" => p
18 | - role="togglebutton" => button ( + aria-pressed )
19 |
20 | The position is synced by a minimalist fork of the [Drei Html component](/drei/misc/html)
21 |
22 | Inside an A11y component, you can access the hover, focused and pressed state through the useA11y() hook.
23 | This hook returns the context of the A11y component.
24 |
25 | The A11yAnnouncer is used to communicate with screen readers through a div only visible to screen readers.
26 | It uses a [zustand](/zustand) store to update the div with each new message.
27 | The div is roughly like this.
28 |
29 | ```html
30 | {message}
31 | ```
32 |
33 | The A11ySection component appends an HTML section in which a p element describe the content of the section.
34 | Wrapped around some A11y components, it will cause the HTML from those component to be inside the section.
35 |
36 | You would then have a generated DOM that could look something like this.
37 |
38 | ```html
39 |
40 | description
41 |
42 |
43 |
44 |
45 | ```
46 |
47 | For the A11yUserPreferences component, it simply exposes through a context the state of prefers-color-scheme and prefers-reduced-motion media queries.
48 | It watches for change through
49 |
50 | ```javascript
51 | window.matchMedia('(prefers-reduced-motion: reduce)')
52 | window.matchMedia('(prefers-color-scheme: dark)')
53 | ```
54 |
--------------------------------------------------------------------------------
/docs/introduction.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Introduction
3 | description: Quick start about how to make your WebGL accessible with @react-three/a11y and react-three-fiber
4 | nav: 0
5 | ---
6 |
7 |
8 |
9 |
10 |
11 | @react-three/a11y brings accessibility to webGL with easy-to-use react-three-fiber components:
12 |
13 | - Focus and focus indication
14 | - Tab index and keyboard navigation
15 | - Screen reader support and alt-text
16 | - Roles and cursor shapes
17 | - Descriptive links
18 |
19 | You can try a [live demo here](https://n4rzi.csb.app).
20 | Open it with your favourite assistive tech to test it out !
21 |
22 | ```bash
23 | npm install @react-three/a11y
24 | ```
25 |
26 | ## Quick overview to get started
27 |
28 | ### The A11yAnnouncer component
29 |
30 | First, place the A11yAnnouncer component next to the R3F Canvas component. This component is critical since it manage some screen-reader features.
31 |
32 | ```jsx
33 | import { Canvas } from '@react-three/fiber';
34 | import { A11yAnnouncer } from '@react-three/a11y';
35 |
36 | function App() {
37 | return (
38 | <>
39 |
40 |
41 | >
42 | );
43 | }
44 | ```
45 |
46 | ### Then wrap components you want to make accessible with the A11y component
47 |
48 | To add accessibility features to your scene you'll have to wrap components you want to make focusable with the `A11y` component:
49 |
50 | ```jsx
51 | import { A11y } from '@react-three/a11y'
52 | [...]
53 |
54 |
55 |
56 | ```
57 |
58 | `MyComponent` can now receive focus. More accurately, the emulated "focus" will be handled at the `A11y` components which acts as a provider
59 | for children to access its state. But even if objects are focusable, nothing will be displayed or shown by default.
60 |
61 | ## Call function on focus
62 |
63 | The `focusCall` prop of `A11y` will be called each time this component receives focus (usually through tab navigation).
64 |
65 | ```jsx
66 | console.log("in focus")} ... />
67 | ```
68 |
69 | ## Call function on click / keyboard Click
70 |
71 | The `actionCall` prop of `A11y` will be called each time this component gets clicked, focused, keyboard activated etc.
72 |
73 | ```jsx
74 | console.log("clicked")} ... />
75 | ```
76 |
77 | ## Provide a description of the currently focused / hovered element
78 |
79 | When using the `description` prop in combination with the `role` prop, the `A11y` component will provide a description to the screen reader users on focus/hover.
80 | Optionally, you can also show the description to the user on hover by setting `showAltText={true}`.
81 |
82 | ```jsx
83 | // Reads "A rotating red square" to screen readers on focus / hover while also showing it on mouseover
84 |
85 | // Reads "Button, open menu + (description on how to activate depending on the screen reader)" to screen readers on focus / hover
86 | {someFunction()}} ... />
87 | ```
88 |
89 | ## The four roles of the A11y component
90 |
91 | Like in HTML, you can focus different kind of elements and expect different things depending on what you're focusing.
92 |
93 | #### Content
94 |
95 | ```jsx
96 |
97 | ```
98 |
99 | Uses the `default` cursor. This role is meant to provide information to screen readers or to serve as a step for a user to navigate your site using Tab for instance. It's not meant to trigger anything on click or to be activable with the Keyboard. Therefore it won't show a pointer cursor on hover.
100 |
101 | [Read more about role content](/a11y/roles/content)
102 |
103 | #### Button
104 |
105 | ```jsx
106 |
110 | ```
111 |
112 | Uses the `pointer` cursor. Special attributes: `activationMsg`.
113 |
114 | This role is meant to emulate the behaviour of a button or a toggleable button. It will display a cursor pointer when your cursor is over the linked 3D object. It will call a function on click but also on any kind of action that would trigger a focused button (Enter, Double-Tap, ...). It is also actionable by user using a screen reader.
115 |
116 | [Read more about role button](/a11y/roles/button)
117 |
118 | #### ToggleButton
119 |
120 | By using the role togglebutton, you'll emulate a button with two state that will have the `aria-pressed` attribute.
121 | You'll then be able to use the deactivationMsg property in addition to the usual description and activationMsg properties.
122 |
123 | ```jsx
124 |
129 | ```
130 |
131 | Special attributes: `deactivationMsg`
132 |
133 | [Read more about role ToggleButton](/a11y/roles/togglebutton)
134 |
135 | #### Link
136 |
137 | ```jsx
138 |
139 | ```
140 |
141 | Uses the `pointer` cursor. Special attributes: `href`.
142 |
143 | This role is meant to emulate the behaviour of a regular html link. It should be used in combination with something that will trigger navigation on click.
144 |
145 | > [!NOTE]
146 | > Don't forget to provide the href attribute as it is required for screen readers to read it correctly! - It will have no effect on the navigation, it's just used as information
147 |
148 | [Read more about role link](/a11y/roles/link)
149 |
150 | ## Screen Reader Support
151 |
152 | In order to provide informations to screen reader users and use this package at its full potential, fill the `description` prop of all your `A11y` components and use the appropriate `role` prop on each of them.
153 |
154 | ### Use of section
155 |
156 | For screen readers, it might be useful to provide additional information on how to use some unconventional UI.
157 | You can do it by wrapping the concerned part of your code relative to this UI in the A11ySection like so.
158 |
159 | ```jsx
160 |
164 | [...]
165 |
166 | ```
167 |
168 | ## Access user preferences
169 |
170 | The A11yUserPreferences component is available in order to access user preferences such as
171 |
172 | - prefers-reduced-motion
173 | - prefers-color-scheme
174 |
175 | Take a look at [the A11yUserPreferences page](/a11y/access-user-preferences) or the [demo](https://n4rzi.csb.app) to see it in action and how to use it. The demo will adapt to your system preferences.
176 |
177 | ## Additional Features
178 |
179 | Use a custom tabindex with for your A11y components by providing a number to the tabIndex attribute
180 |
181 | ```jsx
182 |
183 | ```
184 |
185 | > [!CAUTION]
186 | > Avoid using `tabindex` values greater than 0. Doing so makes it difficult for people who rely on assistive technology to navigate and operate page content.
187 | > Instead, write the document with the elements in a logical sequence. More about the use of tabIndex on [developer.mozilla.org](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex)
188 |
--------------------------------------------------------------------------------
/docs/logo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pmndrs/react-three-a11y/fc70f2676723ffef85f3a0d7bdef7c463c22e950/docs/logo.jpg
--------------------------------------------------------------------------------
/docs/react-three-a11y.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pmndrs/react-three-a11y/fc70f2676723ffef85f3a0d7bdef7c463c22e950/docs/react-three-a11y.jpg
--------------------------------------------------------------------------------
/docs/roles/button.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Button
3 | description: This page will tell you all you need to know about emulating an accessible button in your react-three-fiber app with @react-three-a11y
4 | nav: 4
5 | ---
6 |
7 | ## Role Button of the A11y component
8 |
9 | This role is meant to emulate the behaviour of a button.
10 | It will display a cursor pointer when your cursor is over the linked 3D object.
11 | It will call a function on click but also on any kind of action that would trigger a focused button (Enter, Double-Tap, ...).
12 | It is also actionnable by user using a screen reader.
13 |
14 | ```jsx
15 | sendEmail()}
20 | ... >
21 |
22 |
23 | ```
24 |
25 | Using it like this makes it focusable to all kind of users.
26 |
27 | You should also use the useA11y() hook within the encapsulated components to adjust the rendering on hover and focus. Doing so greatly improve the accessibility of your page.
28 | Take a look at this code sample to see how to use it.
29 | You can also play with it in [this demo](https://n4rzi.csb.app)
30 |
31 | ```jsx
32 | function Some3DComponent() {
33 | const a11y = useA11y()
34 | return (
35 |
36 |
37 |
43 |
44 | )
45 | }
46 | ```
47 |
48 | You could also specify the optional prop `activationMsg`.
49 | The message withinh activationMsg will be announced by screenreader when the button is activated.
50 |
--------------------------------------------------------------------------------
/docs/roles/content.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Content
3 | description: This page will tell you all you need to know about making your content accessible in your react-three-fiber app with @react-three-a11y
4 | nav: 3
5 | ---
6 |
7 | ## Role Content of the A11y component
8 |
9 | This is the simplest role of all.
10 | You should think of it as the equivalent of an image alt attribute
11 | Whenever you have something in your canvas that is not simply decorative, you should use this role.
12 |
13 | Imagine you're displaying some text with the [three.js TextGeometry](https://threejs.org/docs/#api/en/geometries/TextGeometry)
14 |
15 | A user using a screen reader would not have access to the text.
16 |
17 | Let's say the text is 'Welcome to my website', you could simply do as below.
18 |
19 | ```jsx
20 |
21 |
22 |
23 | ```
24 |
25 | That's it !
26 |
27 | Now if you inspect the dom of your app, you will see that a `` tag has been added with your text inside.
28 | That way, user with a screenreader will be able to read that text too.
29 |
30 | > [!NOTE]
31 | > For people using screen readers it will also sync some kind of focus indicator natively where your text is so people so screen readers users will know where they're currently in your page.
32 |
33 | This role can also be used to serve as a step for a user to navigate your site using Tab for instance.
34 | For that you would need to add the tabIndex prop and the focusCall prop like so.
35 |
36 | ```jsx
37 | someFunction()}
42 | >
43 |
44 |
45 | ```
46 |
47 | On focus, you could rotate the camera to show that second piece of text that would usually have required some scrolling to display.
48 | Use it as you please but keep in mind how it might impact the accessibility.
49 | For this example, screenreader don't trigger focus when swiping their screen so it would benefit people used to navigate through keyboard without hurting screenreader users.
50 |
51 | It's not meant to trigger anything on click or to be activable with the Keyboard. Therefore it won't show a pointer cursor on hover.
52 |
--------------------------------------------------------------------------------
/docs/roles/link.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Link
3 | description: This page will tell you all you need to know about emulating an accessible link in your react-three-fiber app with @react-three-a11y
4 | nav: 6
5 | ---
6 |
7 | ## Role Link of the A11y component
8 |
9 | This role is fairly straightforward
10 | You should think of it as the equivalent of an html link "a" tag
11 | Since it's meant to emulate the behaviour of a regular html link. It should be used in combination with something that will trigger navigation on click.
12 |
13 | ```jsx
14 | {
18 | router.push(`/page`);
19 | }}
20 | >
21 |
22 |
23 | ```
24 |
25 | Using it like this makes it focusable to all kind of users. It will also show a pointer on mouse over.
26 |
27 | You should also use the useA11y() hook within the encapsulated components to adjust the rendering on hover and focus. Doing so greatly improve the accessibility of your page.
28 | Take a look at this code sample to see how to use it.
29 | You can also play with it in [this demo](https://n4rzi.csb.app)
30 |
31 | ```jsx
32 | function Some3DComponent() {
33 | const a11y = useA11y();
34 | return (
35 |
36 |
37 |
43 |
44 | );
45 | }
46 | ```
47 |
48 | > [!IMPORTANT]
49 | > Don't forget to provide the `href` attribute as he is required for screen readers to read it correctly!
50 | > It will have no effect on the navigation, it's just used as information
51 |
--------------------------------------------------------------------------------
/docs/roles/togglebutton.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: ToggleButton
3 | description: This page will tell you all you need to know about emulating an accessible togglable button in your react-three-fiber app with @react-three-a11y
4 | nav: 5
5 | ---
6 |
7 | ## Role ToggleButton of the A11y component
8 |
9 | This is mostly the same as the [button role](/a11y/roles/button).
10 | The difference is that this button will have the aria-pressed attribute and that you'll be able to use the following deactivationMsg property in addition to the usual description and activationMsg properties.
11 |
12 | Since this role is meant to emulate the behaviour of togglable button.
13 | It will display a cursor pointer when your cursor is over the linked 3D object.
14 | It will call a function on click but also on any kind of action that would trigger a focused button (Enter, Double-Tap, ...).
15 | It is also actionnable by user using a screen reader.
16 |
17 | ```jsx
18 |
24 |
25 |
26 | ```
27 |
28 | Using it like this makes it focusable to all kind of users.
29 |
30 | > [!NOTE]
31 | > You might have noticed the startPressed prop. Depending on your need, you might want to have your button starting in a pressed state. This is what this prop is for.
32 |
33 | You should also use the useA11y() hook within the encapsulated components to adjust the rendering on hover and focus and pressed state. Doing so greatly improve the accessibility of your page.
34 | Take a look at this code sample to see how to use it.
35 | You can also play with it in [this demo](https://n4rzi.csb.app)
36 |
37 | ```jsx
38 | function Some3DComponent() {
39 | const a11y = useA11y(); // access pressed, hover and focus
40 | return (
41 |
42 |
43 |
49 |
50 | );
51 | }
52 | ```
53 |
54 | You could also specify the optional prop `activationMsg` and `deactivationMsg`.
55 | Respective message will be announced by screenreader when the button is activated / deactivated.
56 |
--------------------------------------------------------------------------------
/example/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 140,
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "semi": false,
6 | "singleQuote": false,
7 | "trailingComma": "all",
8 | "bracketSpacing": true,
9 | "jsxBracketSameLine": true,
10 | "fluid": false
11 | }
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Playground
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mr-main-a11y-features-test",
3 | "version": "1.0.0",
4 | "license": "MIT",
5 | "description": "This sandbox has been generated!",
6 | "keywords": [],
7 | "main": "src/index.js",
8 | "dependencies": {
9 | "@juggle/resize-observer": "3.2.0",
10 | "@pmndrs/branding": "0.0.4",
11 | "react": ">=18",
12 | "react-scripts": "5.0.1",
13 | "three": "*",
14 | "valtio": "0.6.0"
15 | },
16 | "alias": {
17 | "react": "../node_modules/react",
18 | "react-dom": "../node_modules/react-dom",
19 | "@react-three/fiber": "../node_modules/@react-three/fiber",
20 | "three": "../node_modules/three",
21 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling"
22 | },
23 | "devDependencies": {
24 | "parcel": "^2.5.0",
25 | "typescript": "4.5.2"
26 | },
27 | "scripts": {
28 | "start": "parcel index.html",
29 | "build": "parcel build index.html"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/example/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 |
23 | React App
24 |
25 |
26 |
27 |
28 | You need to enable JavaScript to run this app.
29 |
30 |
31 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/example/src/App.js:
--------------------------------------------------------------------------------
1 | import * as THREE from "three"
2 | import { Canvas, useFrame, useThree } from "@react-three/fiber"
3 | import React, { useRef } from "react"
4 | import { A11y, useA11y, A11yAnnouncer, A11yUserPreferences, useUserPreferences, A11ySection, A11yDebuger } from "../../"
5 | import { ResizeObserver } from "@juggle/resize-observer"
6 | import { proxy, useProxy } from "valtio"
7 | import { Badge } from "@pmndrs/branding"
8 |
9 | const state = proxy({ dark: false, active: 0, rotation: 0, disabled: false, section: undefined })
10 | const geometries = [
11 | new THREE.SphereBufferGeometry(1, 32, 32),
12 | new THREE.TetrahedronBufferGeometry(1.5),
13 | new THREE.TorusBufferGeometry(1, 0.35, 16, 32),
14 | new THREE.OctahedronGeometry(1.5),
15 | new THREE.IcosahedronBufferGeometry(1.5),
16 | ]
17 |
18 | function ToggleButton(props) {
19 | const a11y = useA11y()
20 | return (
21 |
22 |
23 |
24 |
25 | )
26 | }
27 |
28 | function SwitchButton(props) {
29 | const a11y = useA11y()
30 | return (
31 | <>
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | >
41 | )
42 | }
43 |
44 | function Floor(props) {
45 | return (
46 | <>
47 |
48 |
49 |
50 |
51 | >
52 | )
53 | }
54 |
55 | function Nav({ left }) {
56 | const snap = useProxy(state)
57 | const viewport = useThree(state => state.viewport)
58 | const radius = Math.min(12, viewport.width / 2.5)
59 | return (
60 | {
65 | state.rotation = snap.rotation + ((Math.PI * 2) / 5) * (left ? -1 : 1)
66 | state.active = left ? (snap.active === 0 ? 4 : snap.active - 1) : snap.active === 4 ? 0 : snap.active + 1
67 | }}
68 | disabled={snap.disabled}>
69 |
70 |
71 | )
72 | }
73 |
74 | function Diamond({ position, rotation }) {
75 | const a11y = useA11y()
76 | return (
77 |
78 |
79 |
80 |
81 | )
82 | }
83 |
84 | function Shape({ index, active, ...props }) {
85 | const snap = useProxy(state)
86 | const vec = new THREE.Vector3()
87 | const ref = useRef()
88 | const { a11yPrefersState } = useUserPreferences()
89 | useFrame((state, delta) => {
90 | if (snap.disabled) {
91 | return
92 | }
93 | if (a11yPrefersState.prefersReducedMotion) {
94 | const s = active ? 2 : 1
95 | ref.current.scale.set(s, s, s)
96 | ref.current.rotation.y = ref.current.rotation.x = active ? 1.5 : 4
97 | ref.current.position.y = 0
98 | } else {
99 | const s = active ? 2 : 1
100 | ref.current.scale.lerp(vec.set(s, s, s), 0.1)
101 | ref.current.rotation.y = ref.current.rotation.x += delta / (active ? 1.5 : 4)
102 | ref.current.position.y = active ? Math.sin(state.clock.elapsedTime) / 2 : 0
103 | }
104 | })
105 | return (
106 |
107 |
108 |
109 | )
110 | }
111 |
112 | // const ResponsiveText = () => {
113 | // const { viewport } = useThree()
114 | // const posX = useControl("posX", { type: "number", value: 0, min: -20, max: 20 })
115 | // const posY = useControl("posY", { type: "number", value: 0, min: -20, max: 20 })
116 | // const posZ = useControl("posZ", { type: "number", value: 0, min: -20, max: 20 })
117 | // const color = useControl("color", { type: "color", value: "#EC2D2D" })
118 | // const fontSize = useControl("fontSize", { type: "number", value: 16.5, min: 1, max: 100 })
119 | // const maxWidth = useControl("maxWidth", { type: "number", value: 90, min: 1, max: 100 })
120 | // const lineHeight = useControl("lineHeight", { type: "number", value: 0.75, min: 0.1, max: 10 })
121 | // const letterSpacing = useControl("spacing", { type: "number", value: -0.08, min: -0.5, max: 1 })
122 | // const textAlign = useControl("textAlign", {
123 | // type: "select",
124 | // items: ["left", "right", "center", "justify"],
125 | // value: "justify",
126 | // })
127 | // return (
128 | //
139 | // LOREM IPSUM DOLOR SIT AMET, CONSECTETUR ADIPISCING ELIT
140 | //
141 | // )
142 | // }
143 |
144 | function Carroussel() {
145 | const viewport = useThree(state => state.viewport)
146 | const snap = useProxy(state)
147 | const group = useRef()
148 | const radius = Math.min(6, viewport.width / 5)
149 | const { a11yPrefersState } = useUserPreferences()
150 | useFrame(() => {
151 | if (a11yPrefersState.prefersReducedMotion) {
152 | group.current.rotation.y = snap.rotation - Math.PI / 2
153 | } else {
154 | group.current.rotation.y = THREE.MathUtils.lerp(group.current.rotation.y, snap.rotation - Math.PI / 2, 0.1)
155 | }
156 | })
157 | return (
158 |
159 | {["sphere", "pyramid", "donut", "octahedron", "icosahedron"].map((name, i) => (
160 |
161 |
167 |
168 | ))}
169 |
170 | )
171 | }
172 |
173 | const CarrousselAll = () => {
174 | const snap = useProxy(state)
175 |
176 | return (
177 | <>
178 |
181 |
182 |
183 |
184 |
185 | (state.dark = !snap.dark)}
190 | activationMsg="Lower light enabled"
191 | deactivationMsg="Lower light disabled"
192 | disabled={snap.disabled}
193 | debug={true}
194 | a11yElStyle={{ marginLeft: "-40px" }}>
195 |
196 |
197 |
198 | >
199 | )
200 | }
201 |
202 | export default function App() {
203 | // const sectionRef = useCallback(node => {
204 | // console.log(node)
205 | // sectionRefref.current = node
206 | // }, [])
207 | const snap = useProxy(state)
208 |
209 | return (
210 |
211 |
212 |
213 |
214 | {/* */}
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 | (state.disabled = !snap.disabled)}
229 | activationMsg="Scene activated"
230 | deactivationMsg="Scene disabled">
231 |
232 |
233 |
234 | {/*
235 |
236 |
237 |
238 |
239 | */}
240 |
241 |
242 |
243 |
244 |
245 | )
246 | }
247 |
--------------------------------------------------------------------------------
/example/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import ReactDOM from 'react-dom/client';
3 | import "./styles.css"
4 | import App from "./App"
5 |
6 | const rootElement = document.getElementById("root")
7 | const root = ReactDOM.createRoot(rootElement)
8 | root.render( )
9 |
--------------------------------------------------------------------------------
/example/src/index.tsx:
--------------------------------------------------------------------------------
1 | import * as THREE from "three"
2 | import ReactDOM from 'react-dom/client';
3 | import React, { useRef, useState } from "react"
4 | import { Canvas, useFrame } from "@react-three/fiber"
5 | import { A11y, useA11y, A11yAnnouncer, A11yUserPreferences, useUserPreferences, A11ySection, A11yDebuger } from "../../"
6 |
7 | /* just to test tsx autocomplete etc */
8 | function Box(props: JSX.IntrinsicElements["mesh"]) {
9 | const mesh = useRef(null!)
10 | const [hovered, setHover] = useState(false)
11 | const [active, setActive] = useState(false)
12 | useFrame((state, delta) => (mesh.current.rotation.x += 0.01))
13 | return (
14 | {}} href="/">
15 | setActive(!active)}
20 | onPointerOver={event => setHover(true)}
21 | onPointerOut={event => setHover(false)}>
22 |
23 |
24 |
25 |
26 | )
27 | }
28 |
29 | const rootElement = document.getElementById("root")
30 | const root = React.useMemo(() => ReactDOM.createRoot(rootElement), [rootElement])
31 |
32 | root.render(
33 |
34 |
35 |
36 |
37 |
38 |
39 | )
40 |
--------------------------------------------------------------------------------
/example/src/styles.css:
--------------------------------------------------------------------------------
1 | *,
2 | *:before,
3 | *:after {
4 | margin: 0;
5 | padding: 0;
6 | box-sizing: border-box;
7 | }
8 |
9 | #root,
10 | main {
11 | position: fixed;
12 | top: 0;
13 | left: 0;
14 | height: 100vh;
15 | width: 100%;
16 | background: #f0f0f0;
17 | transition: background 0.25s ease-in-out;
18 | }
19 |
20 | main.dark {
21 | background: lightgrey;
22 | }
23 |
24 | a {
25 | cursor: pointer;
26 | position: absolute;
27 | bottom: 25px;
28 | left: 50%;
29 | transform: translate3d(-50%, 0, 0);
30 | }
31 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@react-three/a11y",
3 | "version": "3.0.1",
4 | "description": "👩🦯 Provide accessibility support to R3F such as focus indication, keyboard tab index, and screen reader support",
5 | "keywords": [
6 | "a11y",
7 | "accessibility",
8 | "three",
9 | "react",
10 | "react-three-fiber"
11 | ],
12 | "repository": {
13 | "type": "git",
14 | "url": "git+https://github.com/pmndrs/react-three-a11y.git"
15 | },
16 | "bugs": {
17 | "url": "https://github.com/pmndrs/react-three-a11y/issues"
18 | },
19 | "author": "Alaric Baraou",
20 | "license": "MIT",
21 | "main": "dist/index.js",
22 | "module": "dist/a11y.esm.js",
23 | "typings": "dist/index.d.ts",
24 | "files": [
25 | "dist",
26 | "src"
27 | ],
28 | "engines": {
29 | "node": ">=10"
30 | },
31 | "scripts": {
32 | "start": "tsdx watch",
33 | "build": "tsdx build",
34 | "test": "tsdx test --passWithNoTests",
35 | "lint": "tsdx lint --fix",
36 | "prepare": "tsdx build",
37 | "size": "size-limit",
38 | "analyze": "size-limit --why",
39 | "storybook": "start-storybook -p 6006",
40 | "build-storybook": "build-storybook"
41 | },
42 | "peerDependencies": {
43 | "@react-three/fiber": ">=8.0",
44 | "react": ">=18.0",
45 | "react-dom": ">=18.0",
46 | "three": ">=0.137"
47 | },
48 | "dependencies": {
49 | "utility-types": "^3.10.0",
50 | "zustand": "^3.5.13"
51 | },
52 | "husky": {
53 | "hooks": {
54 | "pre-commit": "tsdx lint"
55 | }
56 | },
57 | "prettier": {
58 | "printWidth": 80,
59 | "semi": true,
60 | "singleQuote": true,
61 | "trailingComma": "es5"
62 | },
63 | "size-limit": [
64 | {
65 | "path": "dist/a11y.cjs.production.min.js",
66 | "limit": "10 KB"
67 | },
68 | {
69 | "path": "dist/a11y.esm.js",
70 | "limit": "10 KB"
71 | }
72 | ],
73 | "devDependencies": {
74 | "@babel/core": "^7.14.3",
75 | "@react-three/fiber": "^8.0.8",
76 | "@size-limit/preset-small-lib": "^11.0.2",
77 | "@storybook/addon-essentials": "^7.0.12",
78 | "@storybook/addon-info": "^5.3.21",
79 | "@storybook/addon-links": "^7.0.12",
80 | "@storybook/addons": "^7.0.12",
81 | "@storybook/react": "^7.0.12",
82 | "@types/jest": "^26.0.10",
83 | "@types/lodash-es": "^4.17.3",
84 | "@types/react": "^17.0.5",
85 | "@types/react-dom": "^18.0.4",
86 | "@types/three": "^0.149.0",
87 | "babel-loader": "^8.2.2",
88 | "husky": "^6.0.0",
89 | "react": "^18.0.0",
90 | "react-dom": "^18.0.0",
91 | "react-is": "^18.0.0",
92 | "size-limit": "^11.0.2",
93 | "three": "^0.149.0",
94 | "tsdx": "^0.14.1",
95 | "tslib": "^2.1.0",
96 | "typescript": "^4.7.4"
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/src/A11y.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, useState, useContext } from 'react';
2 | import { useThree } from '@react-three/fiber';
3 | import useAnnounceStore from './announceStore';
4 | import { useA11ySectionContext } from './A11ySection';
5 | import { stylesHiddenButScreenreadable } from './A11yConsts';
6 | import { Html } from './Html';
7 |
8 | interface A11yCommonProps {
9 | role: 'button' | 'togglebutton' | 'link' | 'content' | 'image';
10 | children: React.ReactNode;
11 | description: string;
12 | tabIndex?: number;
13 | showAltText?: boolean;
14 | focusCall?: (...args: any[]) => any;
15 | debug?: boolean;
16 | a11yElStyle?: Object;
17 | hidden?: boolean;
18 | dragThreshold?: number;
19 | }
20 |
21 | type RoleProps =
22 | | {
23 | role: 'content';
24 | activationMsg?: never;
25 | deactivationMsg?: never;
26 | actionCall?: never;
27 | href?: never;
28 | disabled?: never;
29 | startPressed?: never;
30 | tag?: 'p' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
31 | }
32 | | {
33 | role: 'button';
34 | activationMsg?: string;
35 | deactivationMsg?: never;
36 | actionCall?: () => any;
37 | href?: never;
38 | disabled?: boolean;
39 | startPressed?: never;
40 | tag?: never;
41 | }
42 | | {
43 | role: 'togglebutton';
44 | activationMsg?: string;
45 | deactivationMsg?: string;
46 | actionCall?: () => any;
47 | href?: never;
48 | disabled?: boolean;
49 | startPressed?: boolean;
50 | tag?: never;
51 | }
52 | | {
53 | role: 'link';
54 | activationMsg?: never;
55 | deactivationMsg?: never;
56 | actionCall: () => any;
57 | href: string;
58 | disabled?: never;
59 | startPressed?: never;
60 | tag?: never;
61 | }
62 | | {
63 | role: 'image';
64 | activationMsg?: never;
65 | deactivationMsg?: never;
66 | actionCall?: never;
67 | href?: never;
68 | disabled?: never;
69 | startPressed?: never;
70 | tag?: never;
71 | };
72 |
73 | type Props = A11yCommonProps & RoleProps;
74 |
75 | const A11yContext = React.createContext({
76 | focus: false,
77 | hover: false,
78 | pressed: false,
79 | });
80 |
81 | A11yContext.displayName = 'A11yContext';
82 |
83 | const useA11y = () => {
84 | return useContext(A11yContext);
85 | };
86 |
87 | export { useA11y };
88 |
89 | export const A11y: React.FC = ({
90 | children,
91 | description,
92 | activationMsg,
93 | deactivationMsg,
94 | tabIndex,
95 | href,
96 | role,
97 | showAltText = false,
98 | actionCall,
99 | focusCall,
100 | disabled,
101 | debug = false,
102 | a11yElStyle,
103 | startPressed = false,
104 | tag = 'p',
105 | hidden = false,
106 | dragThreshold,
107 | ...props
108 | }) => {
109 | let constHiddenButScreenreadable = Object.assign(
110 | {},
111 | stylesHiddenButScreenreadable,
112 | { opacity: debug ? 1 : 0 },
113 | a11yElStyle
114 | );
115 |
116 | const [a11yState, setA11yState] = useState({
117 | hovered: false,
118 | focused: false,
119 | pressed: startPressed ? startPressed : false,
120 | });
121 |
122 | const a11yScreenReader = useAnnounceStore((state) => state.a11yScreenReader);
123 |
124 | const overHtml = useRef(false);
125 | const overMesh = useRef(false);
126 |
127 | const domElement = useThree((state) => state.gl.domElement);
128 |
129 | // temporary fix to prevent error -> keep track of our component's mounted state
130 | const componentIsMounted = useRef(true);
131 | useEffect(() => {
132 | return () => {
133 | domElement.style.cursor = 'default';
134 | componentIsMounted.current = false;
135 | };
136 | }, []); // Using an empty dependency array ensures this on
137 |
138 | React.Children.only(children);
139 | // @ts-ignore
140 | const handleOnPointerOver = (e) => {
141 | if (e.eventObject) {
142 | overMesh.current = true;
143 | } else {
144 | overHtml.current = true;
145 | }
146 | if (overHtml.current || overMesh.current) {
147 | if (role !== 'content' && role !== 'image' && !disabled) {
148 | domElement.style.cursor = 'pointer';
149 | }
150 | setA11yState({
151 | hovered: true,
152 | focused: a11yState.focused,
153 | pressed: a11yState.pressed,
154 | });
155 | }
156 | };
157 | // @ts-ignore
158 | const handleOnPointerOut = (e) => {
159 | if (e.eventObject) {
160 | overMesh.current = false;
161 | } else {
162 | overHtml.current = false;
163 | }
164 | if (!overHtml.current && !overMesh.current) {
165 | if (componentIsMounted.current) {
166 | domElement.style.cursor = 'default';
167 | setA11yState({
168 | hovered: false,
169 | focused: a11yState.focused,
170 | pressed: a11yState.pressed,
171 | });
172 | }
173 | }
174 | };
175 |
176 | function handleBtnClick() {
177 | //msg is the same need to be clean for it to trigger again in case of multiple press in a row
178 | a11yScreenReader('');
179 | window.setTimeout(() => {
180 | if (typeof activationMsg === 'string') a11yScreenReader(activationMsg);
181 | }, 100);
182 | if (typeof actionCall === 'function') actionCall();
183 | }
184 |
185 | function handleToggleBtnClick() {
186 | if (a11yState.pressed) {
187 | if (typeof deactivationMsg === 'string')
188 | a11yScreenReader(deactivationMsg);
189 | } else {
190 | if (typeof activationMsg === 'string') a11yScreenReader(activationMsg);
191 | }
192 | setA11yState({
193 | hovered: a11yState.hovered,
194 | focused: a11yState.focused,
195 | pressed: !a11yState.pressed,
196 | });
197 | if (typeof actionCall === 'function') actionCall();
198 | }
199 |
200 | const returnHtmlA11yEl = () => {
201 | if (role === 'button' || role === 'togglebutton') {
202 | let disabledBtnAttr = disabled
203 | ? {
204 | disabled: true,
205 | }
206 | : null;
207 | if (role === 'togglebutton') {
208 | return (
209 | {
224 | e.stopPropagation();
225 | if (disabled) {
226 | return;
227 | }
228 | handleToggleBtnClick();
229 | }}
230 | onFocus={() => {
231 | if (typeof focusCall === 'function') focusCall();
232 | setA11yState({
233 | hovered: a11yState.hovered,
234 | focused: true,
235 | pressed: a11yState.pressed,
236 | });
237 | }}
238 | onBlur={() => {
239 | setA11yState({
240 | hovered: a11yState.hovered,
241 | focused: false,
242 | pressed: a11yState.pressed,
243 | });
244 | }}
245 | >
246 | {description}
247 |
248 | );
249 | } else {
250 | //regular btn
251 | return (
252 | {
266 | e.stopPropagation();
267 | if (disabled) {
268 | return;
269 | }
270 | handleBtnClick();
271 | }}
272 | onFocus={() => {
273 | if (typeof focusCall === 'function') focusCall();
274 | setA11yState({
275 | hovered: a11yState.hovered,
276 | focused: true,
277 | pressed: a11yState.pressed,
278 | });
279 | }}
280 | onBlur={() => {
281 | setA11yState({
282 | hovered: a11yState.hovered,
283 | focused: false,
284 | pressed: a11yState.pressed,
285 | });
286 | }}
287 | >
288 | {description}
289 |
290 | );
291 | }
292 | } else if (role === 'link') {
293 | return (
294 | {
306 | e.stopPropagation();
307 | e.preventDefault();
308 | if (typeof actionCall === 'function') actionCall();
309 | }}
310 | onFocus={() => {
311 | if (typeof focusCall === 'function') focusCall();
312 | setA11yState({
313 | hovered: a11yState.hovered,
314 | focused: true,
315 | pressed: a11yState.pressed,
316 | });
317 | }}
318 | onBlur={() => {
319 | setA11yState({
320 | hovered: a11yState.hovered,
321 | focused: false,
322 | pressed: a11yState.pressed,
323 | });
324 | }}
325 | >
326 | {description}
327 |
328 | );
329 | } else {
330 | let tabIndexP = tabIndex
331 | ? {
332 | tabIndex: tabIndex,
333 | }
334 | : null;
335 | if (role === 'image') {
336 | return (
337 | {
351 | setA11yState({
352 | hovered: a11yState.hovered,
353 | focused: false,
354 | pressed: a11yState.pressed,
355 | });
356 | }}
357 | onFocus={() => {
358 | if (typeof focusCall === 'function') focusCall();
359 | setA11yState({
360 | hovered: a11yState.hovered,
361 | focused: true,
362 | pressed: a11yState.pressed,
363 | });
364 | }}
365 | />
366 | );
367 | } else {
368 | const Tag = tag;
369 | return (
370 | {
382 | setA11yState({
383 | hovered: a11yState.hovered,
384 | focused: false,
385 | pressed: a11yState.pressed,
386 | });
387 | }}
388 | onFocus={() => {
389 | if (typeof focusCall === 'function') focusCall();
390 | setA11yState({
391 | hovered: a11yState.hovered,
392 | focused: true,
393 | pressed: a11yState.pressed,
394 | });
395 | }}
396 | >
397 | {description}
398 |
399 | );
400 | }
401 | }
402 | };
403 |
404 | const HtmlAccessibleElement = React.useMemo(returnHtmlA11yEl, [
405 | description,
406 | a11yState,
407 | hidden,
408 | tabIndex,
409 | href,
410 | disabled,
411 | startPressed,
412 | tag,
413 | actionCall,
414 | focusCall,
415 | ]);
416 |
417 | let AltText = null;
418 | if (showAltText && a11yState.hovered) {
419 | AltText = (
420 |
435 |
441 | {description}
442 |
443 |
444 | );
445 | }
446 |
447 | const section = useA11ySectionContext();
448 | let portal = {};
449 | if (section.current instanceof HTMLElement) {
450 | portal = { portal: section };
451 | }
452 |
453 | return (
454 |
461 | {
464 | e.stopPropagation();
465 | if (disabled || (dragThreshold && e.delta > dragThreshold)) {
466 | return;
467 | }
468 | if (role === 'button') {
469 | handleBtnClick();
470 | } else if (role === 'togglebutton') {
471 | handleToggleBtnClick();
472 | } else {
473 | if (typeof actionCall === 'function') actionCall();
474 | }
475 | }}
476 | onPointerOver={handleOnPointerOver}
477 | onPointerOut={handleOnPointerOut}
478 | >
479 | {children}
480 |
488 | {AltText}
489 | {HtmlAccessibleElement}
490 |
491 |
492 |
493 | );
494 | };
495 |
--------------------------------------------------------------------------------
/src/A11yAnnouncer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import useAnnounceStore from './announceStore';
3 |
4 | const offScreenStyle = {
5 | border: 0,
6 | clip: 'rect(0 0 0 0)',
7 | height: '1px',
8 | margin: '-1px',
9 | overflow: 'hidden',
10 | whiteSpace: 'nowrap' as const,
11 | padding: 0,
12 | width: '1px',
13 | position: 'absolute' as const,
14 | };
15 |
16 | export const A11yAnnouncer: React.FC = () => {
17 | const message = useAnnounceStore((state) => state.message);
18 |
19 | useEffect(() => {
20 | const mouseClickListener = (e: MouseEvent) => {
21 | if (
22 | window.document.activeElement?.getAttribute('r3f-a11y') &&
23 | e.detail !== 0
24 | ) {
25 | if (window.document.activeElement instanceof HTMLElement) {
26 | window.document.activeElement.blur();
27 | }
28 | }
29 | };
30 | window.addEventListener('click', mouseClickListener);
31 | return () => {
32 | window.removeEventListener('click', mouseClickListener);
33 | };
34 | });
35 |
36 | return (
37 |
38 | {message}
39 |
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/src/A11yConsts.tsx:
--------------------------------------------------------------------------------
1 | let stylesHiddenButScreenreadable = {
2 | opacity: 0,
3 | borderRadius: '50%',
4 | width: '50px',
5 | height: '50px',
6 | overflow: 'hidden',
7 | transform: 'translateX(-50%) translateY(-50%)',
8 | display: 'inline-block',
9 | userSelect: 'none' as const,
10 | WebkitUserSelect: 'none' as const,
11 | WebkitTouchCallout: 'none' as const,
12 | margin: 0,
13 | };
14 |
15 | export { stylesHiddenButScreenreadable };
16 |
--------------------------------------------------------------------------------
/src/A11yDebuger.tsx:
--------------------------------------------------------------------------------
1 | import React, { useLayoutEffect, useEffect, useState, useRef } from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import { useUserPreferences } from './A11yUserPreferences';
4 | interface Props {}
5 |
6 | export const A11yDebuger: React.FC = ({}) => {
7 | const [el] = useState(() => document.createElement('div'));
8 | const root = React.useMemo(() => ReactDOM.createRoot(el), [el]);
9 | const [boundingStyle, setBoundingStyle] = useState({});
10 | const [debugState, setDebugState] = useState({
11 | prefersDarkScheme: false,
12 | prefersReducedMotion: false,
13 | });
14 | const { a11yPrefersState, setA11yPrefersState } = useUserPreferences();
15 | const domStructureRef = useRef(null);
16 |
17 | useEffect(() => {
18 | el.style.cssText = 'position:fixed;top:0;left:0;';
19 | el.setAttribute('aria-hidden', 'true');
20 | document.body.appendChild(el);
21 | setDebugState({
22 | prefersDarkScheme: a11yPrefersState.prefersDarkScheme,
23 | prefersReducedMotion: a11yPrefersState.prefersReducedMotion,
24 | });
25 | const selectActiveEl = () => {
26 | console.log('focused: ', document.activeElement);
27 | let r3fa11ydebugidref = document.activeElement?.getAttribute(
28 | 'r3f-a11y-debug-id'
29 | );
30 | if (r3fa11ydebugidref) {
31 | document.querySelectorAll('[r3fa11ydebugidref]').forEach((node) => {
32 | //@ts-ignore
33 | node.style.color = null;
34 | });
35 | let refEl = document.querySelector(
36 | '[r3fa11ydebugidref="' + r3fa11ydebugidref + '"]'
37 | );
38 | if (refEl) {
39 | //@ts-ignore
40 | refEl.style.color = 'red';
41 | }
42 | }
43 | };
44 | //@ts-ignore
45 | const root = ReactDOM.createRoot(domStructureRef.current);
46 | console.log('enregistre ev');
47 | document.addEventListener('focus', selectActiveEl, true);
48 | let superinterval = window.setInterval(() => {
49 | let r3fPosId = 0;
50 | //@ts-ignore
51 | let elements = [];
52 | document.querySelectorAll('[r3f-a11y]').forEach((node) => {
53 | node.setAttribute('r3f-a11y-debug-id', '' + r3fPosId);
54 | // let li = document.createElement('li');
55 | // li.innerHTML = node.tagName ;
56 | // //@ts-ignore
57 | // domStructureRef.current.appendChild(li);
58 | elements.push(
59 | //@ts-ignore
60 |
61 | {node.tagName}
62 | {
65 | console.log(node);
66 | let clientRect = node.getBoundingClientRect();
67 | setBoundingStyle({
68 | width: clientRect.width,
69 | height: clientRect.height,
70 | top: clientRect.top,
71 | left: clientRect.left,
72 | });
73 | }}
74 | >
75 | Show
76 |
77 |
78 | );
79 | r3fPosId++;
80 | });
81 | //@ts-ignore
82 | root.render(<>{elements}>);
83 | }, 2000);
84 | return () => {
85 | clearInterval(superinterval);
86 | root.unmount();
87 | console.log('remove ev');
88 | document.removeEventListener('focus', selectActiveEl, true);
89 | };
90 | }, [a11yPrefersState]);
91 |
92 | // @ts-ignore
93 | const handleChange = (e) => {
94 | // @ts-ignore
95 | setA11yPrefersState({
96 | prefersDarkScheme:
97 | e.target.name === 'prefersDarkScheme'
98 | ? e.target.checked
99 | : debugState.prefersDarkScheme,
100 | prefersReducedMotion:
101 | e.target.name === 'prefersReducedMotion'
102 | ? e.target.checked
103 | : debugState.prefersReducedMotion,
104 | });
105 | };
106 |
107 | useLayoutEffect(() => {
108 | return void root.render(
109 | <>
110 |
111 | Prefer dark mode
112 |
118 |
119 |
120 | Prefer reduced motion
121 |
127 |
128 | R3F Dom order
129 |
130 |
144 | >
145 | );
146 | });
147 |
148 | return <>>;
149 | };
150 |
--------------------------------------------------------------------------------
/src/A11ySection.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | useContext,
3 | useEffect,
4 | useRef,
5 | MutableRefObject,
6 | createRef,
7 | } from 'react';
8 | import { useThree } from '@react-three/fiber';
9 | import { stylesHiddenButScreenreadable } from './A11yConsts';
10 |
11 | interface Props {
12 | children: React.ReactNode;
13 | label: string;
14 | description: string;
15 | }
16 |
17 | const A11ySectionContext = React.createContext<
18 | MutableRefObject
19 | >(createRef());
20 |
21 | A11ySectionContext.displayName = 'A11ySectionContext';
22 |
23 | const useA11ySectionContext = () => {
24 | return useContext(A11ySectionContext);
25 | };
26 |
27 | export { useA11ySectionContext };
28 |
29 | export const A11ySection: React.FC = ({
30 | children,
31 | label,
32 | description,
33 | }) => {
34 | const ref = useRef(null);
35 | const refpDesc = useRef(null);
36 | const gl = useThree((state) => state.gl);
37 | const [el] = React.useState(() => document.createElement('section'));
38 | const target = gl.domElement.parentNode;
39 |
40 | useEffect(() => {
41 | // eslint-disable-next-line react-hooks/exhaustive-deps
42 | if (label) {
43 | el.setAttribute('aria-label', label);
44 | }
45 | el.setAttribute('r3f-a11y', 'true');
46 | el.setAttribute(
47 | 'style',
48 | ((styles) => {
49 | return Object.keys(styles).reduce(
50 | (acc, key) =>
51 | acc +
52 | key
53 | .split(/(?=[A-Z])/)
54 | .join('-')
55 | .toLowerCase() +
56 | ':' +
57 | (styles as any)[key] +
58 | ';',
59 | ''
60 | );
61 | })(stylesHiddenButScreenreadable)
62 | );
63 | if (description) {
64 | if (refpDesc.current === null) {
65 | const pDesc = document.createElement('p');
66 | pDesc.innerHTML = description;
67 | pDesc.style.cssText =
68 | 'border: 0!important;clip: rect(1px,1px,1px,1px)!important;-webkit-clip-path: inset(50%)!important;clip-path: inset(50%)!important;height: 1px!important;margin: -1px!important;overflow: hidden!important;padding: 0!important;position: absolute!important;width: 1px!important;white-space: nowrap!important;';
69 | el.prepend(pDesc);
70 | refpDesc.current = pDesc;
71 | } else {
72 | refpDesc.current.innerHTML = description;
73 | }
74 | }
75 | return () => {
76 | if (target) target.removeChild(el);
77 | };
78 | }, [description, label]);
79 |
80 | if (ref.current === null) {
81 | if (target) {
82 | target.appendChild(el);
83 | }
84 | ref.current = el;
85 | }
86 |
87 | return (
88 | <>
89 |
90 | {children}
91 |
92 | >
93 | );
94 | };
95 |
--------------------------------------------------------------------------------
/src/A11yUserPreferences.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState, useContext } from 'react';
2 | interface Props {
3 | children: React.ReactNode;
4 | }
5 |
6 | type aasetA11yPrefersState = {
7 | prefersReducedMotion: boolean;
8 | prefersDarkScheme: boolean;
9 | };
10 |
11 | const A11yUserPreferencesContext = React.createContext({
12 | a11yPrefersState: {
13 | prefersReducedMotion: false,
14 | prefersDarkScheme: false,
15 | },
16 | // tslint:disable:no-unused-variable
17 | setA11yPrefersState: (_state: aasetA11yPrefersState) => {},
18 | // tslint:enable:no-unused-variable
19 | });
20 |
21 | A11yUserPreferencesContext.displayName = 'A11yUserPreferencesContext';
22 |
23 | const useUserPreferences = () => {
24 | return useContext(A11yUserPreferencesContext);
25 | };
26 |
27 | export { useUserPreferences, A11yUserPreferencesContext };
28 |
29 | export const A11yUserPreferences: React.FC = ({ children }) => {
30 | const [a11yPrefersState, setA11yPrefersState] = useState({
31 | prefersReducedMotion: false,
32 | prefersDarkScheme: false,
33 | });
34 |
35 | useEffect(() => {
36 | const prefersReducedMotionMediaQuery = window.matchMedia(
37 | '(prefers-reduced-motion: reduce)'
38 | );
39 | const prefersDarkSchemeMediaQuery = window.matchMedia(
40 | '(prefers-color-scheme: dark)'
41 | );
42 |
43 | setA11yPrefersState({
44 | prefersReducedMotion: prefersReducedMotionMediaQuery.matches,
45 | prefersDarkScheme: prefersDarkSchemeMediaQuery.matches,
46 | });
47 |
48 | const handleReducedMotionPrefChange = (e: MediaQueryListEvent) => {
49 | setA11yPrefersState({
50 | prefersReducedMotion: e.matches,
51 | prefersDarkScheme: prefersDarkSchemeMediaQuery.matches,
52 | });
53 | };
54 | const handleDarkSchemePrefChange = (e: MediaQueryListEvent) => {
55 | setA11yPrefersState({
56 | prefersReducedMotion: prefersReducedMotionMediaQuery.matches,
57 | prefersDarkScheme: e.matches,
58 | });
59 | };
60 |
61 | if (typeof prefersReducedMotionMediaQuery.addEventListener === 'function') {
62 | prefersReducedMotionMediaQuery.addEventListener(
63 | 'change',
64 | handleReducedMotionPrefChange
65 | );
66 | }
67 | if (typeof prefersDarkSchemeMediaQuery.addEventListener === 'function') {
68 | prefersDarkSchemeMediaQuery.addEventListener(
69 | 'change',
70 | handleDarkSchemePrefChange
71 | );
72 | }
73 | return () => {
74 | if (
75 | typeof prefersReducedMotionMediaQuery.removeEventListener === 'function'
76 | ) {
77 | prefersReducedMotionMediaQuery.removeEventListener(
78 | 'change',
79 | handleReducedMotionPrefChange
80 | );
81 | }
82 | if (
83 | typeof prefersDarkSchemeMediaQuery.removeEventListener === 'function'
84 | ) {
85 | prefersDarkSchemeMediaQuery.removeEventListener(
86 | 'change',
87 | handleDarkSchemePrefChange
88 | );
89 | }
90 | };
91 | }, []);
92 |
93 | return (
94 |
103 | {children}
104 |
105 | );
106 | };
107 |
--------------------------------------------------------------------------------
/src/Html.tsx:
--------------------------------------------------------------------------------
1 | //https://raw.githubusercontent.com/pmndrs/drei/master/src/web/Html.tsx
2 | import * as React from 'react';
3 | import ReactDOM from 'react-dom/client';
4 | import {
5 | Vector3,
6 | Group,
7 | Object3D,
8 | Camera,
9 | PerspectiveCamera,
10 | OrthographicCamera,
11 | } from 'three';
12 | import { Assign } from 'utility-types';
13 | import { ReactThreeFiber, useFrame, useThree } from '@react-three/fiber';
14 |
15 | const v1 = new Vector3();
16 | const v2 = new Vector3();
17 | const v3 = new Vector3();
18 |
19 | function calculatePosition(
20 | el: Object3D,
21 | camera: Camera,
22 | size: { width: number; height: number }
23 | ) {
24 | const objectPos = v1.setFromMatrixPosition(el.matrixWorld);
25 | objectPos.project(camera);
26 | const widthHalf = size.width / 2;
27 | const heightHalf = size.height / 2;
28 | return [
29 | objectPos.x * widthHalf + widthHalf,
30 | -(objectPos.y * heightHalf) + heightHalf,
31 | ];
32 | }
33 |
34 | function isObjectBehindCamera(el: Object3D, camera: Camera) {
35 | const objectPos = v1.setFromMatrixPosition(el.matrixWorld);
36 | const cameraPos = v2.setFromMatrixPosition(camera.matrixWorld);
37 | const deltaCamObj = objectPos.sub(cameraPos);
38 | const camDir = camera.getWorldDirection(v3);
39 | return deltaCamObj.angleTo(camDir) > Math.PI / 2;
40 | }
41 |
42 | function objectZIndex(
43 | el: Object3D,
44 | camera: Camera,
45 | zIndexRange: Array
46 | ) {
47 | if (
48 | camera instanceof PerspectiveCamera ||
49 | camera instanceof OrthographicCamera
50 | ) {
51 | const objectPos = v1.setFromMatrixPosition(el.matrixWorld);
52 | const cameraPos = v2.setFromMatrixPosition(camera.matrixWorld);
53 | const dist = objectPos.distanceTo(cameraPos);
54 | const A = (zIndexRange[1] - zIndexRange[0]) / (camera.far - camera.near);
55 | const B = zIndexRange[1] - A * camera.far;
56 | return Math.round(A * dist + B);
57 | }
58 | return undefined;
59 | }
60 |
61 | export interface HtmlProps
62 | extends Omit<
63 | Assign<
64 | React.HTMLAttributes,
65 | ReactThreeFiber.Object3DNode
66 | >,
67 | 'ref'
68 | > {
69 | eps?: number;
70 | portal?: React.MutableRefObject;
71 | zIndexRange?: Array;
72 | }
73 |
74 | export const Html = React.forwardRef(
75 | (
76 | {
77 | children,
78 | eps = 0.001,
79 | style,
80 | className,
81 | portal,
82 | zIndexRange = [16777271, 0],
83 | ...props
84 | }: HtmlProps,
85 | ref: React.Ref
86 | ) => {
87 | const gl = useThree(({ gl }) => gl);
88 | const camera = useThree(({ camera }) => camera);
89 | const scene = useThree(({ scene }) => scene);
90 | const size = useThree(({ size }) => size);
91 | const [el] = React.useState(() => document.createElement('div'));
92 | const root = React.useMemo(() => ReactDOM.createRoot(el), [el]);
93 | const group = React.useRef(null);
94 | const oldZoom = React.useRef(0);
95 | const oldPosition = React.useRef([0, 0]);
96 | const target = portal?.current ?? gl.domElement.parentNode;
97 |
98 | React.useEffect(() => {
99 | if (group.current) {
100 | scene.updateMatrixWorld();
101 | const vec = calculatePosition(group.current, camera, size);
102 | el.style.cssText = `position:absolute;top:0;left:0;transform:translate3d(${vec[0]}px,${vec[1]}px,0);transform-origin:0 0;`;
103 | if (target) {
104 | target.appendChild(el);
105 | }
106 | return () => {
107 | if (target) target.removeChild(el);
108 | root.unmount();
109 | };
110 | }
111 | // eslint-disable-next-line react-hooks/exhaustive-deps
112 | }, [target]);
113 |
114 | const styles: React.CSSProperties = React.useMemo(() => {
115 | return {
116 | position: 'absolute',
117 | transform: 'none',
118 | ...style,
119 | };
120 | }, [style, size]);
121 |
122 | React.useLayoutEffect(() => {
123 | root.render(
124 |
130 | );
131 | });
132 |
133 | useFrame(() => {
134 | if (group.current) {
135 | camera.updateMatrixWorld();
136 | const vec = calculatePosition(group.current, camera, size);
137 |
138 | if (
139 | Math.abs(oldZoom.current - camera.zoom) > eps ||
140 | Math.abs(oldPosition.current[0] - vec[0]) > eps ||
141 | Math.abs(oldPosition.current[1] - vec[1]) > eps
142 | ) {
143 | el.style.display = !isObjectBehindCamera(group.current, camera)
144 | ? 'block'
145 | : 'none';
146 | el.style.zIndex = `${objectZIndex(
147 | group.current,
148 | camera,
149 | zIndexRange
150 | )}`;
151 | el.style.transform = `translate3d(${vec[0]}px,${vec[1]}px,0) scale(1)`;
152 | oldPosition.current = vec;
153 | oldZoom.current = camera.zoom;
154 | }
155 | }
156 | });
157 |
158 | return ;
159 | }
160 | );
161 |
--------------------------------------------------------------------------------
/src/announceStore.tsx:
--------------------------------------------------------------------------------
1 | import create from 'zustand';
2 |
3 | type State = {
4 | message: string;
5 | a11yScreenReader: (message: string) => void;
6 | };
7 |
8 | const useAnnounceStore = create((set) => {
9 | return {
10 | message: '',
11 | a11yScreenReader: (message) => {
12 | set(() => {
13 | return { message: message };
14 | });
15 | },
16 | };
17 | });
18 |
19 | export default useAnnounceStore;
20 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './A11y';
2 | export * from './A11yUserPreferences';
3 | export * from './A11yAnnouncer';
4 | export * from './A11yDebuger';
5 | export { A11ySection } from './A11ySection';
6 |
--------------------------------------------------------------------------------
/stories/Thing.stories.tsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pmndrs/react-three-a11y/fc70f2676723ffef85f3a0d7bdef7c463c22e950/stories/Thing.stories.tsx
--------------------------------------------------------------------------------
/test/blah.test.tsx:
--------------------------------------------------------------------------------
1 | describe('it', () => {
2 | it('has not test yet', () => {});
3 | });
4 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs
3 | "include": ["src", "types"],
4 | "compilerOptions": {
5 | "module": "esnext",
6 | "lib": ["dom", "esnext"],
7 | "importHelpers": true,
8 | // output .d.ts declaration files for consumers
9 | "declaration": true,
10 | // output .js.map sourcemap files for consumers
11 | "sourceMap": true,
12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index
13 | "rootDir": "./src",
14 | // stricter type-checking for stronger correctness. Recommended by TS
15 | "strict": true,
16 | "noFallthroughCasesInSwitch": true,
17 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | // use Node's module resolution algorithm, instead of the legacy TS one
21 | "moduleResolution": "node",
22 | // transpile JSX to React.createElement
23 | "jsx": "react",
24 | // interop between ESM and CJS modules. Recommended by TS
25 | "esModuleInterop": true,
26 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS
27 | "skipLibCheck": true,
28 | // error out if import and file system have a casing mismatch. Recommended by TS
29 | "forceConsistentCasingInFileNames": true,
30 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc`
31 | "noEmit": true
32 | }
33 | }
34 |
--------------------------------------------------------------------------------