├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── docs ├── .babelrc ├── assets │ ├── app.js │ ├── favicon.png │ ├── logo-icon.svg │ ├── prism.css │ └── styles.css ├── index.html ├── package-lock.json ├── package.json └── src │ └── demos.js ├── package-lock.json ├── package.json ├── resources └── icons │ ├── next.svg │ ├── pause.svg │ ├── play.svg │ ├── prev.svg │ ├── sound-off.svg │ └── sound-on.svg ├── src ├── components │ ├── FormattedTime.js │ ├── Slider.js │ └── icons.js ├── constants.js └── index.js └── tests ├── FormattedTime-test.js └── helpers └── configure-enzyme.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"], 3 | "plugins": [ 4 | "transform-class-properties", 5 | ["@babel/plugin-proposal-decorators", { "legacy": true }], 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | # editorconfig.org 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = true 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["standard", "plugin:react/recommended"], 3 | "parser": "babel-eslint", 4 | "plugins": ["react", "import", "jsx-a11y"], 5 | "rules": { 6 | "brace-style": [2, "stroustrup"], 7 | "comma-dangle": [2, "only-multiline"], 8 | "react/prop-types": [2, { "ignore": ["className", "extraClasses", "childClasses", "childrenStyles"] }], 9 | }, 10 | "settings": { 11 | "react": { 12 | "version": "detect", 13 | }, 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # ------------------------ 40 | # Project specific ignores 41 | # ------------------------ 42 | 43 | dist 44 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10" 4 | - "8" 5 | before_install: 6 | - if [[ `npm -v` != 6* ]]; then npm i -g npm@6; fi 7 | install: 8 | - npm install 9 | script: 10 | - npm test 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2016-present, Alexander Wallin 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE 14 | OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 | react-player-controls 5 |
6 |   7 |

8 | 9 | [![npm version](https://badge.fury.io/js/react-player-controls.svg)](https://npmjs.com/package/react-player-controls) 10 | [![Build Status](https://travis-ci.org/alexanderwallin/react-player-controls.svg?branch=master)](https://travis-ci.org/alexanderwallin/react-player-controls) 11 | [![Dependencies](https://img.shields.io/david/alexanderwallin/react-player-controls.svg?style=flat-square)](https://david-dm.org/alexanderwallin/react-player-controls) 12 | [![Dev dependency status](https://david-dm.org/alexanderwallin/react-player-controls/dev-status.svg?style=flat-square)](https://david-dm.org/alexanderwallin/react-player-controls#info=devDependencies) 13 | 14 | This is a minimal set of modular and hopefully useful React components for composing media player interfaces. It is designed for you to compose media player controls yourself using a [small and easy-to-learn API](#api). 15 | 16 | Instead of shipping default but customisable styles, there are [implementation recipies](#recipes) to help you get going quickly. Also check out [the demo site](http://alexanderwallin.github.io/react-player-controls/) to try the components out. 17 | 18 | ⚠️ **NOTE:** This library does not deal with actual media in any way, only the UI. ⚠️ 19 | 20 | ## Table of contents 21 | 22 | * [Installation](#installation) 23 | * [Usage](#usage) 24 | * [API](#api) 25 | * [Recipies](#recipies) 26 | * [Contribute](#contribute) 27 | 28 | 29 | ## Installation 30 | 31 | ```sh 32 | npm i react-player-controls 33 | ``` 34 | 35 | ## Usage 36 | 37 | ```js 38 | // ES2015+ import 39 | import { Slider, Direction } from 'react-player-controls' 40 | 41 | // Using CommonJS 42 | const { Slider, Direction } = require('react-player-controls') 43 | ``` 44 | 45 | ## API 46 | 47 | * [`Direction`](#direction) 48 | * [``](#formattedtime-) 49 | * [``](#playericon-) 50 | * [``](#slider-) 51 | 52 | ### `Direction` 53 | 54 | An enum describing a slider's active axis. 55 | 56 | | Key | Value | 57 | |-----|-------| 58 | | `HORIZONTAL` | `"HORIZONTAL"` | 59 | | `VERTICAL` | `"VERTICAL"` | 60 | 61 | ### `` 62 | 63 | `` translates a number of seconds into the player-friendly format of `m:ss`, or `h:mm:ss` if the total time is one hour or higher. 64 | 65 | ```js 66 | // This will render -1:01:02 67 | 68 | ``` 69 | 70 | | Prop name | Default value | Description | 71 | |-----------|---------------|-------------| 72 | | `numSeconds` | `0` | A number of seconds, positive or negative | 73 | | `className` | `null` | A string to set as the HTML `class` attribute | 74 | | `style` | `{}` | Styles to set on the wrapping `span` element. | 75 | 76 | ### `` 77 | 78 | `` is not really a component in itself, but a container of a number of icon components. 79 | 80 | ```js 81 | 82 | 83 | 84 | 85 | 86 | 87 | ``` 88 | 89 | Any props passed to a `` component will be passed onto the underlying `svg` element. 90 | 91 | ### `` 92 | 93 | The `` helps you build things like volume controls and progress bars. **It does not take a `value` prop**, but expects you to keep track of this yourself and render whatever you want inside it. 94 | 95 | What this component actually does is that it renders an element inside itself, on top of its children, which listens to mouse events and invokes change and intent callbacks with relative, normalised values based on those events. 96 | 97 | ```js 98 | console.log(`hovered at ${intent}`)} 101 | onIntentStart={intent => console.log(`entered with mouse at ${intent}`)} 102 | onIntentEnd={() => console.log('left with mouse')} 103 | onChange={newValue => console.log(`clicked at ${newValue}`)} 104 | onChangeStart={startValue => console.log(`started dragging at ${startValue}`)} 105 | onChangeEnd={endValue => console.log(`stopped dragging at ${endValue}`)} 106 | > 107 | {/* Here we render whatever we want. Nothings is rendered by default. */} 108 | 109 | ``` 110 | 111 | | Prop name | Default value | Description | 112 | |-----------|---------------|-------------| 113 | | `direction` | `Direction.HORIZONTAL` | The slider's direction | 114 | | `onIntent` | `(intent) => {}` | A function that is invoked with the relative, normalised value at which the user is hovering (when not dragging). | 115 | | `onIntentStart` | `(intent) => {}` | A function this is invoked with the relative, normalised value at which the user started hovering the slider (when not dragging). | 116 | | `onIntentEnd` | `() => {}` | A function this is invoked when the mouse left the slider area (when not dragging). | 117 | | `onChange` | `(value) => {}` | A function that is invoked with the latest relative, normalised value that the user has set by either clicking or dragging. | 118 | | `onChangeStart` | `(value) => {}` | A function that is invoked with the relative, normalised value at which the user started changing the slider's value. | 119 | | `onChangeEnd` | `(value) => {}` | A function that is invoked with the relative, normalised value at which the user stopped changing the slider's value. When the component unmounts, this function will be invoked with a value of `null`. | 120 | | `children` | `null` | Child elements. | 121 | | `className` | `null` | A string to set as the HTML `class` attribute. | 122 | | `style` | `{}` | Styles to set on the wrapping `div` element. | 123 | | `overlayZIndex` | 10 | The `z-index` of the invisible overlay that captures mouse events | 124 | 125 | 126 | ## Recipies 127 | 128 |
129 | Styled buttons with icons 130 | 131 | ```js 132 | import { PlayerIcon } from 'react-player-controls' 133 | 134 | // A base component that has base styles applied to it 135 | const PlayerButton = ({ style, children, ...props }) => ( 136 | 153 | ) 154 | 155 | // Compose buttons with matching icons. Use whatever icon library 156 | // you want. If you don't have any particular logic for each of the 157 | // buttons, you might not need this abstraction. 158 | const PlayButton = props => 159 | const PauseButton = props => 160 | const PreviousButton = props => 161 | const NextButton = props => 162 | ``` 163 |
164 | 165 |
166 | Styled slider 167 | 168 | ```js 169 | import { Direction, Slider } from 'react-player-controls' 170 | 171 | const WHITE_SMOKE = '#eee' 172 | const GRAY = '#878c88' 173 | const GREEN = '#72d687' 174 | 175 | // A colored bar that will represent the current value 176 | const SliderBar = ({ direction, value, style }) => ( 177 |
194 | ) 195 | 196 | // A handle to indicate the current value 197 | const SliderHandle = ({ direction, value, style }) => ( 198 |
222 | ) 223 | 224 | // A composite progress bar component 225 | const ProgressBar = ({ isEnabled, direction, value, ...props }) => ( 226 | 239 | 240 | 241 | 242 | ) 243 | 244 | // Now use somewhere 245 | seek(value * currentSong.duration)} 250 | /> 251 | ``` 252 |
253 | 254 |
255 | Playback controls 256 | 257 | ```js 258 | import Icon from 'some-icon-library' 259 | 260 | const PlaybackControls = ({ 261 | isPlaying, 262 | onPlaybackChange, 263 | hasPrevious, 264 | onPrevious, 265 | hasNext, 266 | onNext, 267 | }) => ( 268 |
269 | 272 | 273 | 276 | 277 | 280 |
281 | ) 282 | 283 | // Use PlaybackControls in a player context 284 | player.setIsPlaying(isPlaying)} 287 | hasPrevious={songs.indexOf(currentSong) > 0} 288 | hasNext={songs.indexOf(currentSong) < songs.length - 1} 289 | onPrevious={player.setSong(songs[songs.indexOf(currentSong) - 1])} 290 | onNext={player.setSong(songs[songs.indexOf(currentSong) + 1])} 291 | /> 292 | ``` 293 |
294 | 295 |
296 | Progress bar with buffer 297 | 298 | ```js 299 | import { Direction, Slider } from 'react-player-controls' 300 | 301 | const Bar = ({ style, children, ...props }) => ( 302 |
309 | {children} 310 |
311 | ) 312 | 313 | const ProgressBarWithBuffer = ({ 314 | amountBuffered, 315 | ...props, 316 | }) => ( 317 | 321 | {/* Background bar */} 322 | 323 | 324 | {/* Buffer bar */} 325 | 326 | 327 | {/* Playtime bar */} 328 | 329 | 330 | ) 331 | 332 | // Use buffer bar somewhere 333 | 337 | ``` 338 |
339 | 340 |
341 | Progress bar that shows the target time on hover 342 | 343 | ```js 344 | import { Direction, FormattedTime, Slider } from 'react-player-controls' 345 | 346 | // Create a basic bar that represents time 347 | const TimeBar = ({ children }) => ( 348 |
355 | {children} 356 |
357 | ) 358 | 359 | // Create a tooltip that will show the time 360 | const TimeTooltip = ({ numSeconds, style = {} }) => ( 361 |
378 | 379 |
380 | ) 381 | 382 | // Create a component to keep track of user interactions 383 | class BarWithTimeOnHover extends React.Component { 384 | static propTypes = { 385 | duration: PropTypes.number.isRequired, 386 | } 387 | 388 | constructor(props) { 389 | super(props) 390 | 391 | this.state = { 392 | // This will be a normalised value between 0 and 1, 393 | // or null when not hovered 394 | hoverValue: null, 395 | } 396 | 397 | this.handleIntent = this.handleIntent.bind(this) 398 | this.handleIntentEnd = this.handleIntentEnd.bind(this) 399 | } 400 | 401 | handleIntent(value) { 402 | this.setState({ 403 | hoverValue: value, 404 | }) 405 | } 406 | 407 | handleIntentEnd() { 408 | // Note that this might not be invoked if the user ends 409 | // a control change with the mouse outside of the slider 410 | // element, so you might want to do this inside a 411 | // onChangeEnd callback too. 412 | this.setState({ 413 | hoverValue: null, 414 | }) 415 | } 416 | 417 | render() { 418 | const { duration } = this.props 419 | const { hoverValue } = this.state 420 | 421 | return ( 422 | 430 | 431 | 432 | {hoverValue !== null && ( 433 | 439 | )} 440 | 441 | ) 442 | } 443 | } 444 | 445 | // Let's use it somewhere 446 | 447 | ``` 448 |
449 | 450 |
451 | Base CSS styles (as seen on the docs page) 452 | 453 | ```css 454 | /* Root slider component */ 455 | .slider { 456 | position: relative; 457 | } 458 | 459 | .slider.is-horizontal { 460 | width: 200px; 461 | height: 8px; 462 | } 463 | 464 | .slider.is-vertical { 465 | width: 8px; 466 | height: 200px; 467 | } 468 | 469 | /* Bars – can be progress. value, buffer or whatever */ 470 | .bar { 471 | position: absolute; 472 | border-radius: 50%; 473 | } 474 | 475 | .bar.is-background { 476 | background: #878c88; 477 | } 478 | 479 | .bar.is-value { 480 | background: #72d687; 481 | } 482 | 483 | .bar.is-horizontal { 484 | top: 0; 485 | bottom: 0; 486 | left: 0; 487 | /* width: set dynamically in js */; 488 | height: 100%; 489 | } 490 | 491 | .bar.is-vertical { 492 | right: 0; 493 | bottom: 0; 494 | left: 0; 495 | width: 100%; 496 | /* height: set dynamically in js */; 497 | } 498 | 499 | /* Slider handle */ 500 | .handle { 501 | position: absolute; 502 | width: 16px; 503 | height: 16px; 504 | background: 'green'; 505 | border-radius: 50%; 506 | transform: scale(1); 507 | transition: transform 0.2s; 508 | } 509 | 510 | .handle:hover { 511 | transform: scale(1.3); 512 | } 513 | 514 | .handle.is-horizontal { 515 | top: 0; 516 | /* left: set dynamically in js to x %; */ 517 | margin-top: -4px; 518 | margin-left: -8px; 519 | } 520 | 521 | .handle.is-vertical { 522 | left: 0; 523 | /* bottom: set dynamically in js to x %; */ 524 | margin-bottom: -8px; 525 | margin-left: -4px; 526 | } 527 | ``` 528 |
529 | 530 | 531 | ## Contribute 532 | 533 | Contributions are very welcome, no matter your experience! Please submit a PR and we'll take it from there. 534 | -------------------------------------------------------------------------------- /docs/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"] 3 | } 4 | -------------------------------------------------------------------------------- /docs/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexanderwallin/react-player-controls/f49bb4d9a189df424ff3220abceb4dfb77df5afe/docs/assets/favicon.png -------------------------------------------------------------------------------- /docs/assets/logo-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Artboard 2 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /docs/assets/prism.css: -------------------------------------------------------------------------------- 1 | /** 2 | * prism.js default theme for JavaScript, CSS and HTML 3 | * Based on dabblet (http://dabblet.com) 4 | * @author Lea Verou 5 | */ 6 | 7 | code[class*="language-"], 8 | pre[class*="language-"] { 9 | color: black; 10 | background: none; 11 | text-shadow: 0 1px white; 12 | font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; 13 | text-align: left; 14 | white-space: pre; 15 | word-spacing: normal; 16 | word-break: normal; 17 | word-wrap: normal; 18 | line-height: 1.5; 19 | 20 | -moz-tab-size: 4; 21 | -o-tab-size: 4; 22 | tab-size: 4; 23 | 24 | -webkit-hyphens: none; 25 | -moz-hyphens: none; 26 | -ms-hyphens: none; 27 | hyphens: none; 28 | } 29 | 30 | pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection, 31 | code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection { 32 | text-shadow: none; 33 | background: #b3d4fc; 34 | } 35 | 36 | pre[class*="language-"]::selection, pre[class*="language-"] ::selection, 37 | code[class*="language-"]::selection, code[class*="language-"] ::selection { 38 | text-shadow: none; 39 | background: #b3d4fc; 40 | } 41 | 42 | @media print { 43 | code[class*="language-"], 44 | pre[class*="language-"] { 45 | text-shadow: none; 46 | } 47 | } 48 | 49 | /* Code blocks */ 50 | pre[class*="language-"] { 51 | padding: 1em; 52 | margin: .5em 0; 53 | overflow: auto; 54 | } 55 | 56 | :not(pre) > code[class*="language-"], 57 | pre[class*="language-"] { 58 | background: #f5f2f0; 59 | } 60 | 61 | /* Inline code */ 62 | :not(pre) > code[class*="language-"] { 63 | padding: .1em; 64 | border-radius: .3em; 65 | white-space: normal; 66 | } 67 | 68 | .token.comment, 69 | .token.prolog, 70 | .token.doctype, 71 | .token.cdata { 72 | color: slategray; 73 | } 74 | 75 | .token.punctuation { 76 | color: #999; 77 | } 78 | 79 | .namespace { 80 | opacity: .7; 81 | } 82 | 83 | .token.property, 84 | .token.tag, 85 | .token.boolean, 86 | .token.number, 87 | .token.constant, 88 | .token.symbol, 89 | .token.deleted { 90 | color: #905; 91 | } 92 | 93 | .token.selector, 94 | .token.attr-name, 95 | .token.string, 96 | .token.char, 97 | .token.builtin, 98 | .token.inserted { 99 | color: #690; 100 | } 101 | 102 | .token.operator, 103 | .token.entity, 104 | .token.url, 105 | .language-css .token.string, 106 | .style .token.string { 107 | color: #a67f59; 108 | background: hsla(0, 0%, 100%, .5); 109 | } 110 | 111 | .token.atrule, 112 | .token.attr-value, 113 | .token.keyword { 114 | color: #07a; 115 | } 116 | 117 | .token.function { 118 | color: #DD4A68; 119 | } 120 | 121 | .token.regex, 122 | .token.important, 123 | .token.variable { 124 | color: #e90; 125 | } 126 | 127 | .token.important, 128 | .token.bold { 129 | font-weight: bold; 130 | } 131 | .token.italic { 132 | font-style: italic; 133 | } 134 | 135 | .token.entity { 136 | cursor: help; 137 | } 138 | -------------------------------------------------------------------------------- /docs/assets/styles.css: -------------------------------------------------------------------------------- 1 | @import "/assets/prism.css"; 2 | 3 | @import "https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,700"; 4 | 5 | :root { 6 | --black: #3D463F; 7 | --gray: #878C88; 8 | --lightgray: #d3d3d3; 9 | --white: #fefefe; 10 | --blue: #0E52CE; 11 | --green: #72D687; 12 | 13 | --font: "Source Sans Pro", "Helvetica Neue", Helvetica, Arial, sans-serif; 14 | --monospace: Monaco, Consolas, "Liberation Mono", Menlo, Courier, monospace !important; 15 | } 16 | 17 | * { 18 | box-sizing: border-box; 19 | } 20 | 21 | body, 22 | html { 23 | margin: 0; 24 | padding: 0; 25 | } 26 | 27 | body { 28 | background-color: #fafafa; 29 | color: var(--black); 30 | font-family: var(--font); 31 | } 32 | 33 | /* 34 | * Layout 35 | */ 36 | 37 | .site-container { 38 | display: flex; 39 | flex-wrap: wrap; 40 | width: 90%; 41 | max-width: 960px; 42 | margin: 0 auto; 43 | padding-bottom: 40px; 44 | } 45 | 46 | .site-header { 47 | display: flex; 48 | flex-flow: wrap row; 49 | width: 100%; 50 | margin-bottom: 20px; 51 | padding: 20px 0; 52 | border-bottom: 1px solid #e8e8e8; 53 | } 54 | 55 | .site-sidebar { 56 | width: 25%; 57 | } 58 | 59 | .site-content { 60 | width: 75%; 61 | } 62 | 63 | /* 64 | * Typography 65 | */ 66 | 67 | .site-title { 68 | flex: 2; 69 | margin: 0; 70 | font-size: 1.5em; 71 | } 72 | 73 | .site-title img { 74 | vertical-align: middle; 75 | margin: -3px 10px 0 0; 76 | } 77 | 78 | .site-title .badge { 79 | display: inline-block; 80 | margin-left: 10px; 81 | } 82 | 83 | .site-title-text { 84 | display: inline-block; 85 | vertical-align: middle; 86 | line-height: 1em; 87 | } 88 | 89 | .site-title-text small { 90 | display: block; 91 | font-size: 14px; 92 | font-weight: normal; 93 | color: var(--gray); 94 | line-height: 1.44; 95 | } 96 | 97 | .external-nav { 98 | flex: 1; 99 | line-height: 48px; 100 | text-align: right; 101 | } 102 | 103 | .external-nav a { 104 | color: var(--gray); 105 | } 106 | 107 | .external-nav a&:hover { 108 | color: var(--black); 109 | } 110 | 111 | h5 { 112 | // margin-bottom: 1em; 113 | font-size: 0.75em; 114 | font-weight: normal; 115 | color: var(--gray); 116 | text-transform: uppercase; 117 | letter-spacing: 1px; 118 | } 119 | 120 | a { 121 | color: var(--blue); 122 | text-decoration: none; 123 | } 124 | 125 | a&:hover { 126 | color: saturate(lighten(var(--blue), 30%), 20%); 127 | } 128 | 129 | pre, 130 | code { 131 | font-family: var(--monospace); 132 | } 133 | 134 | /* 135 | * Sidebar / navigation 136 | */ 137 | 138 | nav ul, 139 | nav li 140 | menu ul, 141 | menu li { 142 | margin: 0; 143 | padding: 0; 144 | list-style: none; 145 | } 146 | 147 | .site-sidebar { 148 | 149 | } 150 | 151 | .site-nav { 152 | line-height: 2; 153 | font-size: 12px; 154 | } 155 | 156 | .site-nav ul { 157 | margin-bottom: 1em; 158 | } 159 | 160 | .site-nav ul a { 161 | display: inline-block; 162 | } 163 | 164 | /* 165 | * Page content 166 | */ 167 | 168 | .component-group { 169 | margin-bottom: 40px; 170 | border-bottom: 1px solid #eee; 171 | } 172 | 173 | .component-group-title { 174 | margin: 16px 0 40px; 175 | } 176 | 177 | .component-content { 178 | margin-bottom: 40px; 179 | } 180 | 181 | .component-title { 182 | font-size: 1.125em; 183 | } 184 | 185 | .ComponentDemo { 186 | display: flex; 187 | flex-direction: row; 188 | flex-wrap: wrap; 189 | background: var(--white); 190 | border: 1px solid #eee; 191 | border-radius: 2px; 192 | } 193 | 194 | .ComponentDemo .ComponentDemo-code, 195 | .ComponentDemo .ComponentDemo-settings, 196 | .ComponentDemo .ComponentDemo-results { 197 | position: relative; 198 | padding: 40px 16px 24px; 199 | font-size: 12px; 200 | } 201 | 202 | .ComponentDemo .ComponentDemo-code::before, 203 | .ComponentDemo .ComponentDemo-settings::before, 204 | .ComponentDemo .ComponentDemo-results::before { 205 | position: absolute; 206 | top: 16px; 207 | left: 16px; 208 | font-family: var(--font); 209 | font-size: 10px; 210 | color: var(--gray); 211 | letter-spacing: 2px; 212 | line-height: 14px; 213 | text-transform: uppercase; 214 | } 215 | 216 | .ComponentDemo .ComponentDemo-code { 217 | width: 100%; 218 | margin: 0; 219 | padding-top: 40px; 220 | background: var(--white); 221 | border-bottom: 1px solid #eee; 222 | line-height: 1.6; 223 | } 224 | 225 | .ComponentDemo .ComponentDemo-code::before { 226 | content: 'JS'; 227 | } 228 | 229 | .ComponentDemo-settings { 230 | flex: 1; 231 | border-right: 1px solid #eee; 232 | } 233 | 234 | .ComponentDemo-settings::before { 235 | content: 'State Settings'; 236 | } 237 | 238 | .ComponentDemo-settings label { 239 | display: flex; 240 | align-items: center; 241 | margin-bottom: 4px; 242 | line-height: 24px; 243 | } 244 | 245 | .ComponentDemo-settings code { 246 | flex-grow: 1; 247 | } 248 | 249 | .ComponentDemo-settings code + input, 250 | .ComponentDemo-settings code + select { 251 | margin-left: 10px; 252 | padding: 4px; 253 | font-family: var(--monospace); 254 | } 255 | 256 | .ComponentDemo-results { 257 | flex: 1; 258 | } 259 | 260 | .ComponentDemo-results::before { 261 | content: 'Results'; 262 | } 263 | 264 | 265 | /* 266 | * Footer 267 | */ 268 | 269 | .site-footer { 270 | width: 100%; 271 | margin-top: 60px; 272 | padding: 20px 0 0; 273 | border-top: 1px solid #eee; 274 | font-size: 12px; 275 | text-align: center; 276 | color: var(--gray); 277 | } 278 | 279 | 280 | /** 281 | * Mobile 282 | */ 283 | 284 | @media (max-width: 767px) { 285 | .site-container { 286 | display: block; 287 | } 288 | 289 | .external-nav { 290 | display: none; 291 | } 292 | 293 | .site-sidebar { 294 | width: auto; 295 | margin-bottom: 40px; 296 | } 297 | 298 | .site-content { 299 | width: auto; 300 | } 301 | 302 | .component-group { 303 | border: none; 304 | } 305 | 306 | .ComponentDemo { 307 | display: block; 308 | } 309 | 310 | .ComponentDemo-code, 311 | .ComponentDemo-settings, 312 | .ComponentDemo-results { 313 | width: 100%; 314 | } 315 | 316 | .ComponentDemo-settings { 317 | border-right: none; 318 | border-bottom: 1px solid #eee; 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | react-player-controls 5 | 6 | 7 | 8 | 9 | 10 |
11 | 28 | 46 |
47 |

Base components

48 |
49 |
50 |
51 |

52 | <FormattedTime /> 53 |

54 |
55 |
56 |
57 |
58 |

59 | <PlayerIcon /> 60 |

61 |
62 |
63 |
64 |
65 |

<Slider />

66 |
67 |
68 |
69 |
70 | 77 |
78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-player-controls-docs", 3 | "scripts": { 4 | "build": "browserify src/demos.js -t babelify -o assets/app.js", 5 | "build:watch": "watchify src/demos.js -t babelify -o assets/app.js", 6 | "dev": "npm-run-all --parallel '*:watch' 'start'", 7 | "start": "live-server --port=8293" 8 | }, 9 | "private": true, 10 | "dependencies": { 11 | "prismjs": "^1.24.0" 12 | }, 13 | "devDependencies": { 14 | "@babel/core": "^7.0.0", 15 | "@babel/preset-env": "^7.0.0", 16 | "@babel/preset-react": "^7.0.0", 17 | "babelify": "^10.0.0-beta.1", 18 | "browserify": "^16.5.2", 19 | "live-server": "^1.2.1", 20 | "livereload": "^0.9.1", 21 | "npm-run-all": "^3.0.0", 22 | "watchify": "^3.11.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /docs/src/demos.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent, useState } from 'react' 2 | import ReactDOM from 'react-dom' 3 | // // import PropTypes from 'prop-types' 4 | import Prism from 'prismjs' 5 | import 'prismjs/components/prism-jsx.js' 6 | 7 | import { 8 | Direction, 9 | FormattedTime, 10 | PlayerIcon, 11 | Slider, 12 | } from '../../dist/index.js' 13 | 14 | const WHITE_SMOKE = '#eee' 15 | const GRAY = '#878c88' 16 | const GREEN = '#72d687' 17 | 18 | // 19 | // FormattedTime demo 20 | // 21 | class FormattedTimeDemo extends PureComponent { 22 | constructor (props) { 23 | super(props) 24 | 25 | this.state = { 26 | numSeconds: 100, 27 | } 28 | } 29 | 30 | render () { 31 | const { numSeconds } = this.state 32 | 33 | return ( 34 |
35 |
 36 |           `,
 39 |               Prism.languages.jsx
 40 |             )
 41 |           }} />
 42 |         
43 | 44 |
45 | 49 |
50 | 51 |
52 | 53 |
54 |
55 | ) 56 | } 57 | } 58 | 59 | // 60 | // PlayerIcon demo 61 | // 62 | class PlayerIconDemo extends PureComponent { 63 | render () { 64 | return ( 65 |
66 |
 67 |           \n` +
 70 |               `\n` +
 71 |               `\n` +
 72 |               `\n` +
 73 |               `\n` +
 74 |               `\n`,
 75 |               Prism.languages.jsx
 76 |             )
 77 |           }} />
 78 |         
79 | 80 |
81 | 82 | 83 | 84 | 85 | 86 | 87 |
88 |
89 | ) 90 | } 91 | } 92 | 93 | // 94 | // Slider demo 95 | // 96 | const SliderBar = ({ direction, value, style }) => ( 97 |
117 | ) 118 | 119 | const SliderHandle = ({ direction, value, style }) => ( 120 |
144 | ) 145 | 146 | function SliderDemo () { 147 | const [isEnabled, setIsEnabled] = useState(true) 148 | const [direction, setDirection] = useState(Direction.HORIZONTAL) 149 | const [value, setValue] = useState(0) 150 | const [lastValueStart, setLastValueStart] = useState(0) 151 | const [lastValueEnd, setLastValueEnd] = useState(0) 152 | const [lastIntent, setLastIntent] = useState(0) 153 | const [lastIntentStart, setLastIntentStart] = useState(0) 154 | const [lastIntentEndCount, setLastIntentEndCount] = useState(0) 155 | 156 | return ( 157 |
158 |
159 |          
` + 162 | `\nconst SliderHandle = ({ direction, value, style }) =>
` + 163 | `\n` + 164 | `\n this.setState(() => ({ value: newValue }))}\n onChangeStart={startValue => this.setState(() => ({ lastValueStart: startValue }))}\n onChangeEnd={endValue => this.setState(() => ({ lastValueEnd: endValue }))}\n onIntent={intent => this.setState(() => ({ lastIntent: intent }))}\n onIntentStart={intent => this.setState(() => ({ lastIntentStart: intent }))}\n onIntentEnd={() => this.setState(() => ({ lastIntentEndCount: this.state.lastIntentEndCount + 1 }))}\n style={sliderStylesHere}\n>` + 165 | `\n ` + 166 | `\n ` + 167 | `\n ` + 168 | `\n ` + 169 | `\n`, 170 | Prism.languages.jsx 171 | ) 172 | }} /> 173 |
174 | 175 |
176 | 180 | 181 | 188 | 189 | 193 | 194 | 198 | 199 | 203 | 204 | 208 | 209 | 213 | 214 | 218 |
219 | 220 |
221 | setLastIntentEndCount(lastIntentEndCount + 1)} 230 | style={{ 231 | width: direction === Direction.HORIZONTAL ? 200 : 24, 232 | height: direction === Direction.HORIZONTAL ? 24 : 130, 233 | // borderRadius: 4, 234 | // background: WHITE_SMOKE, 235 | transition: direction === Direction.HORIZONTAL ? 'width 0.1s' : 'height 0.1s', 236 | cursor: isEnabled === true ? 'pointer' : 'default', 237 | }} 238 | > 239 | 240 | 241 | 242 | 243 | 244 |
245 |
246 | ) 247 | } 248 | 249 | ReactDOM.render(, document.querySelector('.component-demo[data-component="FormattedTime"]')) 250 | ReactDOM.render(, document.querySelector('.component-demo[data-component="PlayerIcon"]')) 251 | ReactDOM.render(, document.querySelector('.component-demo[data-component="Slider"]')) 252 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-player-controls", 3 | "version": "2.0.0-beta.1", 4 | "description": "UI components for media players", 5 | "main": "dist/index.js", 6 | "files": [ 7 | "dist", 8 | "resources", 9 | "src" 10 | ], 11 | "scripts": { 12 | "prebuild": "rimraf dist", 13 | "build": "babel --out-dir dist src", 14 | "dev": "npm run build -- --watch", 15 | "lint": "eslint src tests", 16 | "test": "mocha --require @babel/register --require jsdom-global/register --require tests/helpers/configure-enzyme.js tests/*.js", 17 | "test:w": "npm test -- -w" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/alexanderwallin/react-player-controls.git" 22 | }, 23 | "author": "Reactify (http://reactifymusic.com)", 24 | "contributors": [ 25 | "Alexander Wallin (http://alexanderwallin.com)" 26 | ], 27 | "license": "ISC", 28 | "bugs": { 29 | "url": "https://github.com/alexanderwallin/react-player-controls/issues" 30 | }, 31 | "homepage": "http://alexanderwallin.github.io/react-player-controls/", 32 | "devDependencies": { 33 | "@babel/cli": "^7.11.6", 34 | "@babel/core": "^7.2.2", 35 | "@babel/plugin-proposal-decorators": "^7.2.3", 36 | "@babel/preset-env": "^7.2.3", 37 | "@babel/preset-react": "^7.0.0", 38 | "@babel/register": "^7.0.0", 39 | "babel-eslint": "^10.0.1", 40 | "babel-plugin-transform-class-properties": "^6.11.5", 41 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 42 | "chai": "^4.2.0", 43 | "chai-enzyme": "^1.0.0-beta.1", 44 | "enzyme": "^3.8.0", 45 | "enzyme-adapter-react-16": "^1.7.1", 46 | "eslint": "^7.10.0", 47 | "eslint-config-standard": "^14.1.1", 48 | "eslint-plugin-import": "^2.22.1", 49 | "eslint-plugin-jsx-a11y": "^6.3.1", 50 | "eslint-plugin-node": "^11.1.0", 51 | "eslint-plugin-promise": "^4.2.1", 52 | "eslint-plugin-react": "^7.21.3", 53 | "eslint-plugin-standard": "^4.0.1", 54 | "jsdom-global": "^3.0.2", 55 | "mocha": "^5.2.0", 56 | "mocha-jsdom": "^2.0.0", 57 | "react": "^16.13.1", 58 | "react-dom": "^16.13.1", 59 | "rimraf": "^2.6.3", 60 | "sinon": "^7.2.2" 61 | }, 62 | "peerDependencies": { 63 | "react": "^16.8.0" 64 | }, 65 | "dependencies": { 66 | "autobind-decorator": "^2.4.0", 67 | "prop-types": "^15.6.2", 68 | "react-use-gesture": "^7.0.16" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /resources/icons/next.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | next 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /resources/icons/pause.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | pause 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /resources/icons/play.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Artboard 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /resources/icons/prev.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | prev 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /resources/icons/sound-off.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | sound-off 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /resources/icons/sound-on.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | sound-on 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/FormattedTime.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const { number, object, string } = PropTypes 5 | 6 | const padZero = digit => 7 | `${digit < 10 ? '0' : ''}${digit}` 8 | 9 | /** 10 | * Time formatter that turns seconds into h:mm:ss 11 | */ 12 | class FormattedTime extends Component { 13 | static propTypes = { 14 | numSeconds: number, 15 | className: string, 16 | style: object, 17 | } 18 | 19 | static defaultProps = { 20 | numSeconds: 0, 21 | className: null, 22 | style: {}, 23 | } 24 | 25 | getFormattedTime () { 26 | const { numSeconds } = this.props 27 | 28 | const prefix = numSeconds < 0 ? '-' : '' 29 | const absNumSeconds = Math.abs(numSeconds) 30 | 31 | const hours = Math.floor(absNumSeconds / 3600) 32 | const minutes = Math.floor((absNumSeconds % 3600) / 60) 33 | const seconds = Math.floor(absNumSeconds) % 60 34 | 35 | return hours > 0 36 | ? `${prefix}${hours}:${padZero(minutes)}:${padZero(seconds)}` 37 | : `${prefix}${minutes}:${padZero(seconds)}` 38 | } 39 | 40 | render () { 41 | const { style, className } = this.props 42 | 43 | return ( 44 | 45 | {this.getFormattedTime()} 46 | 47 | ) 48 | } 49 | } 50 | 51 | export default FormattedTime 52 | -------------------------------------------------------------------------------- /src/components/Slider.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { useGesture } from 'react-use-gesture' 4 | 5 | import { Direction } from '../constants.js' 6 | 7 | function noop () {} 8 | 9 | function clamp (value, min, max) { 10 | return Math.min(Math.max(value, min), max) 11 | } 12 | 13 | function getRectFromBounds (bounds) { 14 | return typeof bounds === 'function' ? bounds() : bounds 15 | } 16 | 17 | function getHorizontalValue (rect, x) { 18 | const scrollX = (window.pageXOffset !== undefined) 19 | ? window.pageXOffset 20 | : (document.documentElement || document.body.parentNode || document.body).scrollLeft 21 | const pageX = scrollX + x 22 | const dLeft = clamp(pageX - (rect.left + scrollX), 0, rect.width) 23 | return dLeft / rect.width 24 | } 25 | 26 | function getVerticalValue (rect, y) { 27 | const scrollY = (window.pageYOffset !== undefined) 28 | ? window.pageYOffset 29 | : (document.documentElement || document.body.parentNode || document.body).scrollTop 30 | const pageY = scrollY + y 31 | const dTop = clamp(pageY - (rect.top + scrollY), 0, rect.height) 32 | return 1 - (dTop / rect.height) 33 | } 34 | 35 | function getSliderValue (bounds, direction, xy) { 36 | const rect = getRectFromBounds(bounds) 37 | return direction === Direction.HORIZONTAL 38 | ? getHorizontalValue(rect, xy[0]) 39 | : getVerticalValue(rect, xy[1]) 40 | } 41 | 42 | /** 43 | * Slider 44 | * 45 | * A wrapper around that may be used to 46 | * compose slider controls such as volume sliders or progress bars. 47 | */ 48 | function Slider ({ 49 | direction = Direction.HORIZONTAL, 50 | onIntent = noop, 51 | onIntentStart = noop, 52 | onIntentEnd = noop, 53 | onChange = noop, 54 | onChangeStart = noop, 55 | onChangeEnd = noop, 56 | children = null, 57 | className = null, 58 | style = {}, 59 | overlayZIndex = 10, 60 | }) { 61 | const $el = React.createRef() 62 | const bounds = () => $el.current.getBoundingClientRect() 63 | 64 | const bind = useGesture( 65 | { 66 | onMoveStart: ({ dragging, xy }) => !dragging && onIntentStart(getSliderValue(bounds, direction, xy)), 67 | onMove: ({ dragging, xy }) => !dragging && onIntent(getSliderValue(bounds, direction, xy)), 68 | onMoveEnd: ({ dragging }) => !dragging && onIntentEnd(), 69 | onDragStart: ({ xy }) => onChangeStart(getSliderValue(bounds, direction, xy)), 70 | onDrag: ({ xy }) => onChange(getSliderValue(bounds, direction, xy)), 71 | onDragEnd: ({ xy }) => onChangeEnd(getSliderValue(bounds, direction, xy)), 72 | }, 73 | { 74 | axis: direction === Direction.HORIZONTAL ? 'x' : 'y', 75 | filterTaps: true, 76 | } 77 | ) 78 | 79 | return ( 80 |
88 | {children} 89 | 90 |
101 |
102 | ) 103 | } 104 | 105 | Slider.propTypes = { 106 | direction: PropTypes.oneOf([Direction.HORIZONTAL, Direction.VERTICAL]), 107 | isEnabled: PropTypes.bool, 108 | onIntent: PropTypes.func, 109 | onIntentStart: PropTypes.func, 110 | onIntentEnd: PropTypes.func, 111 | onChange: PropTypes.func, 112 | onChangeStart: PropTypes.func, 113 | onChangeEnd: PropTypes.func, 114 | children: PropTypes.node, 115 | className: PropTypes.string, 116 | style: PropTypes.object, 117 | overlayZIndex: PropTypes.number, 118 | } 119 | 120 | function arePropsEqual (prevProps, nextProps) { 121 | for (const prop in nextProps) { 122 | if (nextProps[prop] !== prevProps[prop]) { 123 | return false 124 | } 125 | } 126 | return true 127 | } 128 | 129 | export default React.memo(Slider, arePropsEqual) 130 | -------------------------------------------------------------------------------- /src/components/icons.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | /** 4 | * Play 5 | */ 6 | export const Play = (props) => 7 | 8 | 9 | 10 | 11 | /** 12 | * Pause 13 | */ 14 | export const Pause = (props) => 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | /** 23 | * Previous 24 | */ 25 | export const Previous = (props) => 26 | 27 | 28 | 29 | 30 | /** 31 | * Next 32 | */ 33 | export const Next = (props) => 34 | 35 | 36 | 37 | 38 | /** 39 | * Sound on 40 | */ 41 | export const SoundOn = (props) => 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | /** 51 | * Sound off 52 | */ 53 | export const SoundOff = (props) => 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const Direction = { 2 | HORIZONTAL: 'HORIZONTAL', 3 | VERTICAL: 'VERTICAL', 4 | } 5 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import FormattedTime from './components/FormattedTime.js' 2 | import * as PlayerIcon from './components/icons.js' 3 | import Slider from './components/Slider.js' 4 | 5 | export { Direction } from './constants.js' 6 | export { FormattedTime, PlayerIcon, Slider } 7 | -------------------------------------------------------------------------------- /tests/FormattedTime-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import React from 'react' 3 | import { shallow, mount } from 'enzyme' 4 | import chai, { expect } from 'chai' 5 | import chaiEnzyme from 'chai-enzyme' 6 | 7 | import FormattedTime from '../src/components/FormattedTime.js' 8 | 9 | chai.use(chaiEnzyme()) 10 | 11 | describe('', () => { 12 | it('defaults to 0:00', () => { 13 | const time = mount() 14 | 15 | expect(time.props().numSeconds).to.equal(0) 16 | expect(time.text()).to.equal('0:00') 17 | }) 18 | 19 | it('renders a formatted time of mm:ss', () => { 20 | const time = mount() 21 | expect(time.text()).to.equal('11:40') 22 | }) 23 | 24 | it('renders m:ss when less than 10 minutes', () => { 25 | const time = mount() 26 | expect(time.text()).to.equal('1:22') 27 | }) 28 | 29 | it('renders negative time', () => { 30 | const time1 = mount() 31 | expect(time1.text()).to.equal('-1:22') 32 | 33 | const time2 = mount() 34 | expect(time2.text()).to.equal('-1:20:10') 35 | }) 36 | 37 | it('renders hours when needed', () => { 38 | const time = mount() 39 | expect(time.text()).to.equal('1:00:01') 40 | }) 41 | 42 | it('rounds down rendered time to integers', () => { 43 | const time1 = mount() 44 | expect(time1.text()).to.equal('1:00:01') 45 | 46 | const time2 = mount() 47 | expect(time2.text()).to.equal('1:00:59') 48 | 49 | const time3 = mount() 50 | expect(time3.text()).to.equal('1:00:01') 51 | }) 52 | 53 | it('should accept a className', () => { 54 | let time = shallow() 55 | expect(time.props().className).to.contain('CustomClassName') 56 | }) 57 | 58 | it('should accept custom styles', () => { 59 | const time = shallow() 60 | expect(time.props().style).to.eql({ fontSize: 100 }) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /tests/helpers/configure-enzyme.js: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme' 2 | import Adapter from 'enzyme-adapter-react-16' 3 | 4 | Enzyme.configure({ adapter: new Adapter() }) 5 | --------------------------------------------------------------------------------