├── .assets └── demo.gif ├── .eslintrc ├── .github └── workflows │ ├── main.yml │ └── size.yml ├── .gitignore ├── LICENSE ├── README.md ├── example ├── .npmignore ├── index.css ├── index.html ├── index.tsx ├── package.json ├── tsconfig.json └── yarn.lock ├── package.json ├── src ├── forwardRefWithAs.tsx └── index.tsx ├── styles.css ├── test └── RadioGroup.test.tsx ├── tsconfig.json └── yarn.lock /.assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/palmerhq/radio-group/fe79ea64a5cf8e213f0e48f8baa6b356cc5ca17d/.assets/demo.gif -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["react-app"] 3 | } 4 | -------------------------------------------------------------------------------- /.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 | dist 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 The Palmer Group 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `@palmerhq/radio-group` 2 | 3 | An accessible [WAI-ARIA 1.1-compliant Radio Group](https://www.w3.org/TR/wai-aria-practices-1.1/#radiobutton) React component. 4 | 5 | Radio group demo 6 | 7 | 8 | 9 | 10 | - [`@palmerhq/radio-group`](#palmerhqradio-group) 11 | - [Installation](#installation) 12 | - [Usage](#usage) 13 | - [Usage with Formik v2](#usage-with-formik-v2) 14 | - [API Reference](#api-reference) 15 | - [``](#radiogroup-) 16 | - [`labelledBy?: string`](#labelledby-string) 17 | - [`onChange: (value: any) => void`](#onchange-value-any--void) 18 | - [`children: React.ComponentType[]`](#children-reactcomponenttyperadioprops) 19 | - [`value: any`](#value-any) 20 | - [`as?: React.ComponentType`](#as-reactcomponenttype) 21 | - [`autoFocus?: boolean`](#autofocus-boolean) 22 | - [``](#radio) 23 | - [`value: any`](#value-any-1) 24 | - [`onFocus?: () => void`](#onfocus---void) 25 | - [`onBlur?: () => void`](#onblur---void) 26 | - [`as?: React.ComponentType`](#as-reactcomponenttype-1) 27 | - [Underlying DOM Structure](#underlying-dom-structure) 28 | - [Overriding Styles](#overriding-styles) 29 | - [Accessibility Features](#accessibility-features) 30 | - [Authors](#authors) 31 | 32 | 33 | 34 | ## Installation 35 | 36 | ``` 37 | yarn add @palmerhq/radio-group 38 | ``` 39 | 40 | Or try it out in your browser on [CodeSandbox](https://codesandbox.io/embed/qxxnwzvy0w) 41 | 42 | > Note: This package uses `Array.prototype.findIndex`, so be sure that you have properly polyfilled. 43 | 44 | ## Usage 45 | 46 | ```tsx 47 | import * as React from 'react'; 48 | import { RadioGroup, Radio } from '@palmerhq/radio-group'; 49 | import '@palmerhq/radio-group/styles.css'; // use the default styles 50 | 51 | function App() { 52 | const [value, setValue] = React.useState(); 53 | 54 | return ( 55 | <> 56 |

Color

57 | setValue(value)} 61 | > 62 | Blue 63 | Red 64 | Green 65 | 66 | 67 | ); 68 | } 69 | ``` 70 | 71 | ### Usage with Formik v2 72 | 73 | ```tsx 74 | import * as React from 'react'; 75 | import { Formik, Form, useField } from 'formik'; 76 | import { RadioGroup, Radio } from '@palmerhq/radio-group'; 77 | import '@palmerhq/radio-group/styles.css'; // use the default styles 78 | 79 | function FRadioGroup(props) { 80 | const [{ onChange, onBlur, ...field }] = useField(props.name); 81 | return ( 82 | 89 | ); 90 | } 91 | 92 | function App() { 93 | return ( 94 | { 100 | setTimeout(() => { 101 | alert(JSON.stringify(values, null, 2)); 102 | setSubmitting(false); 103 | }, 500); 104 | }} 105 | > 106 |
107 |

Color

108 | 109 | Blue 110 | Red 111 | Green 112 | 113 |
114 |
115 | ); 116 | } 117 | ``` 118 | 119 | ## API Reference 120 | 121 | ### `` 122 | 123 | This renders a `div` and will pass through all props to the DOM element. It's children must be `` components. 124 | 125 | #### `labelledBy?: string` 126 | 127 | This should match the `id` you used to label the radio group. 128 | 129 | ```tsx 130 |

Color

131 | 132 | {/* ... */} 133 | 134 | ``` 135 | 136 | #### `onChange: (value: any) => void` 137 | 138 | A callback function that will be fired with the `value` of the newly selected item. 139 | 140 | ```tsx 141 | import * as React from 'react'; 142 | import { RadioGroup, Radio } from '@palmerhq/radio-group'; 143 | import '@palmerhq/radio-group/styles.css'; // use the default styles 144 | 145 | function App() { 146 | const [value, setValue] = React.useState(); 147 | 148 | return ( 149 | <> 150 |

Color

151 | setValue(value)} 155 | > 156 | Blue 157 | Red 158 | Green 159 | 160 | 161 | ); 162 | } 163 | ``` 164 | 165 | #### `children: React.ComponentType[]` 166 | 167 | **Required** 168 | 169 | The children of a `` can **ONLY** be `` components. In order to support compliant keyboard behavior, each sibling must know the value of the whole group and so `React.Children.map` is used internally. 170 | 171 | ```tsx 172 |

Color

173 | 174 | {/* ... */} 175 | 176 | ``` 177 | 178 | #### `value: any` 179 | 180 | **Required** 181 | 182 | The current value of the radio group. This is shallowly compared to each `value` prop of the child `` components to determine which item is active. 183 | 184 | #### `as?: React.ComponentType` 185 | 186 | Component to use a the wrapper. Default is `
`. 187 | 188 | #### `autoFocus?: boolean` 189 | 190 | Whether to autoFocus the selected radio option. 191 | 192 | ### `` 193 | 194 | This renders a `div` with a data attribute `data-palmerhq-radio` and all the relevant perfect aria attributes. The React component will pass through all props to the DOM element. 195 | 196 | #### `value: any` 197 | 198 | **Required** 199 | 200 | The value of the radio button. This will be set / passed back to the `` when the item is selected. 201 | 202 | #### `onFocus?: () => void` 203 | 204 | Callback function for when the item is focused. When focused, a data attribute `data-palmerhq-radio-focus` is set to `"true"`. You can thus apply the selector to manage focus style like so: 205 | 206 | ```css 207 | [data-palmerhq-radio][data-palmerhq-radio-focus='true'] { 208 | background: blue; 209 | } 210 | ``` 211 | 212 | #### `onBlur?: () => void` 213 | 214 | Callback function for when the item is blurred 215 | 216 | #### `as?: React.ComponentType` 217 | 218 | Component to use as radio. Default is `
`. 219 | 220 | ### Underlying DOM Structure 221 | 222 | For reference, the underlying HTML DOM structure are all `div`s and looks as follows. 223 | 224 | ```html 225 |
226 |
233 | Red 234 |
235 |
242 | Green 243 |
244 |
251 | Blue 252 |
253 |
254 | ``` 255 | 256 | ### Overriding Styles 257 | 258 | These are the default styles. Copy and paste the following into your app to customize them. 259 | 260 | ```css 261 | [data-palmerhq-radio-group] { 262 | padding: 0; 263 | margin: 0; 264 | list-style: none; 265 | } 266 | 267 | [data-palmerhq-radio-group]:focus { 268 | outline: none; 269 | } 270 | 271 | [data-palmerhq-radio] { 272 | border: 2px solid transparent; 273 | border-radius: 5px; 274 | display: inline-block; 275 | position: relative; 276 | padding: 0.125em; 277 | padding-left: 1.5em; 278 | padding-right: 0.5em; 279 | cursor: default; 280 | outline: none; 281 | } 282 | 283 | [data-palmerhq-radio] + [data-palmerhq-radio] { 284 | margin-left: 1em; 285 | } 286 | 287 | [data-palmerhq-radio]::before, 288 | [data-palmerhq-radio]::after { 289 | position: absolute; 290 | top: 50%; 291 | left: 7px; 292 | transform: translate(-20%, -50%); 293 | content: ''; 294 | } 295 | 296 | [data-palmerhq-radio]::before { 297 | width: 14px; 298 | height: 14px; 299 | border: 1px solid hsl(0, 0%, 66%); 300 | border-radius: 100%; 301 | background-image: linear-gradient(to bottom, hsl(300, 3%, 93%), #fff 60%); 302 | } 303 | 304 | [data-palmerhq-radio]:active::before { 305 | background-image: linear-gradient( 306 | to bottom, 307 | hsl(300, 3%, 73%), 308 | hsl(300, 3%, 93%) 309 | ); 310 | } 311 | 312 | [data-palmerhq-radio][aria-checked='true']::before { 313 | border-color: hsl(216, 80%, 50%); 314 | background: hsl(217, 95%, 68%); 315 | background-image: linear-gradient( 316 | to bottom, 317 | hsl(217, 95%, 68%), 318 | hsl(216, 80%, 57%) 319 | ); 320 | } 321 | 322 | [data-palmerhq-radio][aria-checked='true']::after { 323 | display: block; 324 | border: 0.1875em solid #fff; 325 | border-radius: 100%; 326 | transform: translate(25%, -50%); 327 | } 328 | 329 | [data-palmerhq-radio][aria-checked='mixed']:active::before, 330 | [data-palmerhq-radio][aria-checked='true']:active::before { 331 | background-image: linear-gradient( 332 | to bottom, 333 | hsl(216, 80%, 57%), 334 | hsl(217, 95%, 68%) 60% 335 | ); 336 | } 337 | 338 | [data-palmerhq-radio]:hover::before { 339 | border-color: hsl(216, 94%, 65%); 340 | } 341 | 342 | [data-palmerhq-radio][data-palmerhq-radio-focus='true'] { 343 | border-color: hsl(216, 94%, 73%); 344 | background-color: hsl(216, 80%, 97%); 345 | } 346 | 347 | [data-palmerhq-radio]:hover { 348 | background-color: hsl(216, 80%, 92%); 349 | } 350 | ``` 351 | 352 | ## Accessibility Features 353 | 354 | - Uses CSS attribute selectors for synchronizing `aria-checked` state with the visual state indicator. 355 | - Uses CSS `:hover` and `:focus` pseudo-selectors for styling visual keyboard focus and hover. 356 | - Focus indicator encompasses both radio button and label, making it easier to perceive which option is being chosen. 357 | - Hover changes background of both radio button and label, making it easier to perceive that clicking either the label or button will activate the radio button. 358 | 359 |

Keyboard Support

360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 377 | 378 | 379 | 380 | 387 | 388 | 389 | 390 | 397 | 398 | 399 | 400 | 407 | 408 | 409 | 410 | 417 | 418 | 419 | 420 | 427 | 428 | 429 |
KeyFunction
Tab 372 |
    373 |
  • Moves focus to the checked radio button in the radiogroup.
  • 374 |
  • If a radio button is not checked, focus moves to the first radio button in the group.
  • 375 |
376 |
Space 381 |
    382 |
  • If the radio button with focus is not checked, changes the state to checked.
  • 383 |
  • Otherwise, does nothing.
  • 384 |
  • Note: The state where a radio is not checked only occurs on page load.
  • 385 |
386 |
Right arrow 391 |
    392 |
  • Moves focus to and checks the next radio button in the group.
  • 393 |
  • If focus is on the last radio button, moves focus to the first radio button.
  • 394 |
  • The state of the previously checked radio button is changed to unchecked.
  • 395 |
396 |
Down arrow 401 |
    402 |
  • Moves focus to and checks the next radio button in the group.
  • 403 |
  • If focus is on the last radio button, moves focus to the first radio button.
  • 404 |
  • The state of the previously checked radio button is changed to unchecked.
  • 405 |
406 |
Left arrow 411 |
    412 |
  • Moves focus to and checks the previous radio button in the group.
  • 413 |
  • If focus is on the first radio button, moves focus to and checks the last radio button.
  • 414 |
  • The state of the previously checked radio button is changed to unchecked.
  • 415 |
416 |
Up arrow 421 |
    422 |
  • Moves focus to and checks the previous radio button in the group.
  • 423 |
  • If focus is on the first radio button, moves focus to and checks the last radio button.
  • 424 |
  • The state of the previously checked radio button is changed to unchecked.
  • 425 |
426 |
430 | 431 |

Role, Property, State, and Tabindex Attributes

432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 469 | 470 | 471 | 472 | 473 | 474 | 481 | 482 | 483 | 484 | 485 | 486 | 495 | 496 | 497 | 498 | 499 | 500 | 507 | 508 | 509 | 510 | 511 | 512 | 519 | 520 | 521 |
RoleAttributesElementUsage
radiogroupdiv 447 |
    448 |
  • Identifies the div element as a container for a group of radio buttons.
  • 449 |
  • Is not focusable because focus is managed using a roving tabindex strategy as described below.
  • 450 |
451 |
aria-labelledby="[IDREF]"divRefers to the element that contains the label of the radio group.
radiodiv 464 |
    465 |
  • Identifies the div element as an ARIA radio button.
  • 466 |
  • The accessible name is computed from the child text content of the div element.
  • 467 |
468 |
tabindex="-1"div 475 |
    476 |
  • Makes the element focusable but not part of the page Tab sequence.
  • 477 |
  • Applied to all radio buttons contained in the radio group except for one that is included in the page Tab sequence.
  • 478 |
  • This approach to managing focus is described in the section on roving tabindex.
  • 479 |
480 |
tabindex="0"div 487 |
    488 |
  • Makes the radio button focusable and includes it in the page Tab sequence.
  • 489 |
  • Set on only one radio in the radio group.
  • 490 |
  • On page load, is set on the first radio button in the radio group.
  • 491 |
  • Moves with focus inside the radio group so the most recently focused radio button is included in the page Tab sequence.
  • 492 |
  • This approach to managing focus is described in the section on roving tabindex.
  • 493 |
494 |
aria-checked="false"div 501 |
    502 |
  • Identifies radio buttons which are not checked.
  • 503 |
  • CSS attribute selectors (e.g. [aria-checked="false"]) are used to synchronize the visual states with the value of the aria-checked attribute.
  • 504 |
  • The CSS ::before pseudo-class is used to indicate visual state of unchecked radio buttons to support high contrast settings in operating systems and browsers.
  • 505 |
506 |
aria-checked="true"div 513 |
    514 |
  • Identifies the radio button which is checked.
  • 515 |
  • CSS attribute selectors (e.g. [aria-checked="true"]) are used to synchronize the visual states with the value of the aria-checked attribute.
  • 516 |
  • The CSS ::before pseudo-class is used to indicate visual state of checked radio buttons to support high contrast settings in operating systems and browsers.
  • 517 |
518 |
522 | 523 | ## Authors 524 | 525 | - Jared Palmer ([@jaredpalmer](https://twitter.com/jaredpalmer)) 526 | 527 | --- 528 | 529 | > MIT License 530 | -------------------------------------------------------------------------------- /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /example/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 3 | margin: 6rem; 4 | } -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Radio Group 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { Formik, Form, useField } from 'formik'; 4 | import { RadioGroup, Radio } from '../.'; 5 | import * as Yup from 'yup'; 6 | import './index.css'; 7 | import '../styles.css'; 8 | 9 | function FRadioGroup(props) { 10 | const [{ onChange, onBlur, ...field }] = useField(props.name); 11 | return ( 12 | 19 | ); 20 | } 21 | 22 | function App() { 23 | return ( 24 | { 30 | await new Promise(r => setTimeout(r, 500)); 31 | alert(JSON.stringify(values, null, 2)); 32 | }} 33 | > 34 |
35 | 36 | Foo 37 | Biz 38 | Boop 39 | 40 |
41 |
42 | ); 43 | } 44 | 45 | ReactDOM.render(, document.getElementById('app')); 46 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "@palmerhq/radio-group": "file:..", 12 | "formik": "^2.2.0", 13 | "react": ">=16.8.0", 14 | "react-app-polyfill": "^1.0.0", 15 | "react-dom": ">=16.8.0", 16 | "yup": "^0.29.3" 17 | }, 18 | "alias": { 19 | "react": "../node_modules/react", 20 | "react-dom": "../node_modules/react-dom/profiling", 21 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 22 | }, 23 | "devDependencies": { 24 | "@types/react": "^16.9.11", 25 | "@types/react-dom": "^16.8.4", 26 | "parcel": "^1.12.3", 27 | "typescript": "^3.4.5" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "types": ["node"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@palmerhq/radio-group", 3 | "author": "Jared Palmer", 4 | "module": "dist/radio-group.esm.js", 5 | "main": "dist/index.js", 6 | "typings": "dist/index.d.ts", 7 | "version": "1.0.2", 8 | "license": "MIT", 9 | "files": [ 10 | "dist", 11 | "src", 12 | "styles.css" 13 | ], 14 | "engines": { 15 | "node": ">=10" 16 | }, 17 | "scripts": { 18 | "start": "tsdx watch", 19 | "build": "tsdx build", 20 | "test": "tsdx test --passWithNoTests", 21 | "lint": "tsdx lint", 22 | "prepare": "tsdx build", 23 | "size": "size-limit", 24 | "analyze": "size-limit --why" 25 | }, 26 | "peerDependencies": { 27 | "react": ">=16" 28 | }, 29 | "husky": { 30 | "hooks": { 31 | "pre-commit": "tsdx lint" 32 | } 33 | }, 34 | "prettier": { 35 | "printWidth": 80, 36 | "semi": true, 37 | "singleQuote": true, 38 | "trailingComma": "es5" 39 | }, 40 | "size-limit": [ 41 | { 42 | "path": "dist/radio-group.cjs.production.min.js", 43 | "limit": "10 KB" 44 | }, 45 | { 46 | "path": "dist/radio-group.esm.js", 47 | "limit": "10 KB" 48 | } 49 | ], 50 | "devDependencies": { 51 | "@size-limit/preset-small-lib": "^4.6.0", 52 | "@types/react": "^16.9.52", 53 | "@types/react-dom": "^16.9.8", 54 | "husky": "^4.3.0", 55 | "react": "^16.14.0", 56 | "react-dom": "^16.14.0", 57 | "size-limit": "^4.6.0", 58 | "tsdx": "^0.14.1", 59 | "tslib": "^2.0.3", 60 | "typescript": "^4.0.3" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/forwardRefWithAs.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copied from Reach UI utils... 3 | * 4 | * It fixes TypeScript type inferencing to work with 5 | */ 6 | 7 | import * as React from 'react'; 8 | 9 | /** 10 | * React.Ref uses the readonly type `React.RefObject` instead of 11 | * `React.MutableRefObject`, We pretty much always assume ref objects are 12 | * mutable (at least when we create them), so this type is a workaround so some 13 | * of the weird mechanics of using refs with TS. 14 | */ 15 | export type AssignableRef = 16 | | { 17 | bivarianceHack(instance: ValueType | null): void; 18 | }['bivarianceHack'] 19 | | React.MutableRefObject 20 | | null; 21 | 22 | //////////////////////////////////////////////////////////////////////////////// 23 | // The following types help us deal with the `as` prop. 24 | // I kind of hacked around until I got this to work using some other projects, 25 | // as a rough guide, but it does seem to work so, err, that's cool? Yay TS! 🙃 26 | // P = additional props 27 | // T = type of component to render 28 | 29 | export type As = React.ElementType; 30 | 31 | export type PropsWithAs< 32 | ComponentType extends As, 33 | ComponentProps 34 | > = ComponentProps & 35 | Omit< 36 | React.ComponentPropsWithRef, 37 | 'as' | keyof ComponentProps 38 | > & { 39 | as?: ComponentType; 40 | }; 41 | 42 | export type PropsFromAs< 43 | ComponentType extends As, 44 | ComponentProps 45 | > = (PropsWithAs & { as: ComponentType }) & 46 | PropsWithAs; 47 | 48 | export type ComponentWithForwardedRef< 49 | ElementType extends React.ElementType, 50 | ComponentProps 51 | > = React.ForwardRefExoticComponent< 52 | ComponentProps & 53 | React.HTMLProps> & 54 | React.ComponentPropsWithRef 55 | >; 56 | 57 | export interface ComponentWithAs { 58 | // These types are a bit of a hack, but cover us in cases where the `as` prop 59 | // is not a JSX string type. Makes the compiler happy so 🤷‍♂️ 60 | ( 61 | props: PropsWithAs 62 | ): React.ReactElement | null; 63 | ( 64 | props: PropsWithAs 65 | ): React.ReactElement | null; 66 | 67 | displayName?: string; 68 | propTypes?: React.WeakValidationMap< 69 | PropsWithAs 70 | >; 71 | contextTypes?: React.ValidationMap; 72 | defaultProps?: Partial>; 73 | } 74 | 75 | /** 76 | * This is a hack for sure. The thing is, getting a component to intelligently 77 | * infer props based on a component or JSX string passed into an `as` prop is 78 | * kind of a huge pain. Getting it to work and satisfy the constraints of 79 | * `forwardRef` seems dang near impossible. To avoid needing to do this awkward 80 | * type song-and-dance every time we want to forward a ref into a component 81 | * that accepts an `as` prop, we abstract all of that mess to this function for 82 | * the time time being. 83 | * 84 | * TODO: Eventually we should probably just try to get the type defs above 85 | * working across the board, but ain't nobody got time for that mess! 86 | * 87 | * @param Comp 88 | */ 89 | export function forwardRefWithAs( 90 | comp: ( 91 | props: PropsFromAs, 92 | ref: React.RefObject 93 | ) => React.ReactElement | null 94 | ) { 95 | return (React.forwardRef(comp as any) as unknown) as ComponentWithAs< 96 | ComponentType, 97 | Props 98 | >; 99 | } 100 | 101 | /* 102 | Test components to make sure our dynamic As prop components work as intended 103 | type PopupProps = { 104 | lol: string; 105 | children?: React.ReactNode | ((value?: number) => JSX.Element); 106 | }; 107 | export const Popup = forwardRefWithAs( 108 | ({ as: Comp = 'input', lol, className, children, ...props }, ref) => { 109 | return ( 110 | 111 | {typeof children === 'function' ? children(56) : children} 112 | 113 | ); 114 | } 115 | ); 116 | export const TryMe1: React.FC = () => { 117 | return ; 118 | }; 119 | export const TryMe2: React.FC = () => { 120 | let ref = React.useRef(null); 121 | return ; 122 | }; 123 | 124 | export const TryMe4: React.FC = () => { 125 | return ; 126 | }; 127 | export const Whoa: React.FC<{ 128 | help?: boolean; 129 | lol: string; 130 | name: string; 131 | test: string; 132 | }> = props => { 133 | return ; 134 | }; 135 | */ 136 | // export const TryMe3: React.FC = () => { 137 | // return ; 138 | // }; 139 | // let Cool = styled(Whoa)` 140 | // padding: 10px; 141 | // ` 142 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { forwardRefWithAs } from './forwardRefWithAs'; 3 | 4 | const codes = { 5 | RETURN: 13, 6 | SPACE: 32, 7 | END: 35, 8 | HOME: 36, 9 | LEFT: 37, 10 | UP: 38, 11 | RIGHT: 39, 12 | DOWN: 40, 13 | }; 14 | 15 | export interface RadioGroupCtx { 16 | value: V; 17 | otherRadioValues: Siblings; 18 | setChecked: (value: any) => void; 19 | autoFocus: boolean; 20 | touched: boolean; 21 | setTouched: (value: boolean) => void; 22 | } 23 | 24 | const RadioGroupContext = React.createContext>({} as any); 25 | 26 | export interface RadioProps { 27 | value: V; 28 | children: React.ReactNode; 29 | onFocus?: (e: React.FocusEvent) => void; 30 | onBlur?: (e: any) => void; 31 | } 32 | 33 | export interface RadioGroupProps { 34 | labelledBy: string; 35 | children: React.ReactNode; 36 | value: V; 37 | onChange: (value: V) => void; 38 | autoFocus?: boolean; 39 | } 40 | 41 | export const RadioGroup = forwardRefWithAs( 42 | function RadioGroup( 43 | { 44 | labelledBy, 45 | children, 46 | value, 47 | autoFocus = false, 48 | as: Comp = 'div', 49 | ...props 50 | }, 51 | ref 52 | ) { 53 | const { onChange } = props; 54 | const [touched, setTouched] = React.useState(false); 55 | const setChecked = React.useCallback( 56 | v => { 57 | if (onChange) { 58 | onChange(v); 59 | } 60 | }, 61 | [onChange] 62 | ); 63 | 64 | const otherRadioValues = React.Children.map( 65 | children, 66 | child => child.props.value 67 | ); 68 | const ctx = React.useMemo( 69 | () => ({ 70 | value, 71 | otherRadioValues, 72 | setChecked, 73 | autoFocus, 74 | touched, 75 | setTouched, 76 | }), 77 | [otherRadioValues, setChecked, autoFocus, value, touched, setTouched] 78 | ); 79 | return ( 80 | 81 | 88 | {children} 89 | 90 | 91 | ); 92 | } 93 | ); 94 | 95 | export const Radio = forwardRefWithAs, 'div'>(function Radio( 96 | { children, as: Comp = 'div', ...props }, 97 | maybeOuterRef: any 98 | ) { 99 | const [focus, setFocus] = React.useState(false); 100 | const ref = React.useRef(null); 101 | const { onBlur, onFocus } = props; 102 | const ctx = React.useContext(RadioGroupContext); 103 | const { 104 | otherRadioValues, 105 | value, 106 | setChecked, 107 | autoFocus, 108 | touched, 109 | setTouched, 110 | } = ctx; 111 | const index = otherRadioValues.findIndex((i) => i === props.value); 112 | const count = otherRadioValues.length - 1; 113 | const isCurrentRadioSelected = value === props.value; 114 | const valueProp = props.value; 115 | React.useEffect(() => { 116 | if ((autoFocus || touched) && value === valueProp) { 117 | if (maybeOuterRef && maybeOuterRef.current !== null) { 118 | maybeOuterRef.current.focus(); 119 | } else if (ref.current !== null) { 120 | ref.current.focus(); 121 | } 122 | } 123 | }, [value, valueProp, maybeOuterRef, autoFocus, touched]); 124 | 125 | const isFirstRadioOption = index === 0; 126 | const handleKeyDown = React.useCallback( 127 | event => { 128 | event.persist(); 129 | let flag = false; 130 | function setPrevious() { 131 | if (isFirstRadioOption) { 132 | setChecked(otherRadioValues[count]); 133 | } else { 134 | setChecked(otherRadioValues[index - 1]); 135 | } 136 | } 137 | 138 | function setNext() { 139 | if (index === count) { 140 | setChecked(otherRadioValues[0]); 141 | } else { 142 | setChecked(otherRadioValues[index + 1]); 143 | } 144 | } 145 | 146 | switch (event.keyCode) { 147 | case codes.SPACE: 148 | case codes.RETURN: 149 | setChecked(valueProp); 150 | flag = true; 151 | break; 152 | case codes.UP: 153 | case codes.LEFT: 154 | setPrevious(); 155 | flag = true; 156 | break; 157 | case codes.DOWN: 158 | case codes.RIGHT: 159 | setNext(); 160 | flag = true; 161 | break; 162 | default: 163 | break; 164 | } 165 | 166 | setTouched(true); 167 | 168 | if (flag) { 169 | event.stopPropagation(); 170 | event.preventDefault(); 171 | } 172 | }, 173 | [ 174 | isFirstRadioOption, 175 | setChecked, 176 | otherRadioValues, 177 | count, 178 | index, 179 | valueProp, 180 | setTouched, 181 | ] 182 | ); 183 | 184 | const handleClick = React.useCallback(() => { 185 | setChecked(valueProp); 186 | }, [setChecked, valueProp]); 187 | 188 | const handleBlur = React.useCallback( 189 | e => { 190 | if (onBlur) { 191 | onBlur(e); 192 | } 193 | setFocus(false); 194 | setTouched(true); 195 | }, 196 | [onBlur, setTouched] 197 | ); 198 | 199 | const handleFocus = React.useCallback( 200 | e => { 201 | if (onFocus) { 202 | onFocus(e); 203 | } 204 | setFocus(true); 205 | }, 206 | [onFocus] 207 | ); 208 | 209 | const noValueSelected = !value; 210 | const tabIndex = 211 | isCurrentRadioSelected || (noValueSelected && isFirstRadioOption) ? 0 : -1; 212 | return ( 213 | { 225 | if (maybeOuterRef) { 226 | maybeOuterRef.current = el; 227 | } 228 | ref.current = el; 229 | }} 230 | children={children} 231 | /> 232 | ); 233 | }); 234 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | [data-palmerhq-radio-group] { 2 | padding: 0; 3 | margin: 0; 4 | list-style: none; 5 | } 6 | 7 | [data-palmerhq-radio-group]:focus { 8 | outline: none; 9 | } 10 | 11 | [data-palmerhq-radio] { 12 | border: 2px solid transparent; 13 | border-radius: 5px; 14 | display: inline-block; 15 | position: relative; 16 | padding: 0.125em; 17 | padding-left: 1.5em; 18 | padding-right: 0.5em; 19 | cursor: default; 20 | outline: none; 21 | } 22 | 23 | [data-palmerhq-radio]+[data-palmerhq-radio] { 24 | margin-left: 1em; 25 | } 26 | 27 | [data-palmerhq-radio]::before, 28 | [data-palmerhq-radio]::after { 29 | position: absolute; 30 | top: 50%; 31 | left: 7px; 32 | transform: translate(-20%, -50%); 33 | content: ''; 34 | } 35 | 36 | [data-palmerhq-radio]::before { 37 | width: 14px; 38 | height: 14px; 39 | border: 1px solid hsl(0, 0%, 66%); 40 | border-radius: 100%; 41 | background-image: linear-gradient(to bottom, hsl(300, 3%, 93%), #fff 60%); 42 | } 43 | 44 | [data-palmerhq-radio]:active::before { 45 | background-image: linear-gradient(to bottom, hsl(300, 3%, 73%), hsl(300, 3%, 93%)); 46 | } 47 | 48 | [data-palmerhq-radio][aria-checked="true"]::before { 49 | border-color: hsl(216, 80%, 50%); 50 | background: hsl(217, 95%, 68%); 51 | background-image: linear-gradient(to bottom, hsl(217, 95%, 68%), hsl(216, 80%, 57%)); 52 | } 53 | 54 | [data-palmerhq-radio][aria-checked="true"]::after { 55 | display: block; 56 | border: 0.1875em solid #fff; 57 | border-radius: 100%; 58 | transform: translate(25%, -50%); 59 | } 60 | 61 | [data-palmerhq-radio][aria-checked="mixed"]:active::before, 62 | [data-palmerhq-radio][aria-checked="true"]:active::before { 63 | background-image: linear-gradient(to bottom, hsl(216, 80%, 57%), hsl(217, 95%, 68%) 60%); 64 | } 65 | 66 | [data-palmerhq-radio]:hover::before { 67 | border-color: hsl(216, 94%, 65%); 68 | } 69 | 70 | [data-palmerhq-radio][data-palmerhq-radio-focus="true"] { 71 | border-color: hsl(216, 94%, 73%); 72 | background-color: hsl(216, 80%, 97%); 73 | } 74 | 75 | [data-palmerhq-radio]:hover { 76 | background-color: hsl(216, 80%, 92%); 77 | } -------------------------------------------------------------------------------- /test/RadioGroup.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { Radio, RadioGroup } from '../src'; 4 | 5 | describe('it', () => { 6 | it('renders without crashing', () => { 7 | const div = document.createElement('div'); 8 | ReactDOM.render( 9 | {}} labelledBy="foop"> 10 | Foo 11 | Biz 12 | Boop 13 | , 14 | div 15 | ); 16 | ReactDOM.unmountComponentAtNode(div); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /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 | // linter checks for common issues 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | // use Node's module resolution algorithm, instead of the legacy TS one 23 | "moduleResolution": "node", 24 | // transpile JSX to React.createElement 25 | "jsx": "react", 26 | // interop between ESM and CJS modules. Recommended by TS 27 | "esModuleInterop": true, 28 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 29 | "skipLibCheck": true, 30 | // error out if import and file system have a casing mismatch. Recommended by TS 31 | "forceConsistentCasingInFileNames": true, 32 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 33 | "noEmit": true, 34 | } 35 | } 36 | --------------------------------------------------------------------------------